OpenAL + ALURE + Swift3でゲーム用の低遅延なサウンド再生システムを作る

  • 4
    いいね
  • 0
    コメント

概要

  • タイミングにシビアなゲームなどには AVFoundation#AVAudioPlayer は向かない
  • CoreAudio には OpenAL の実装が含まれるのでこれを利用すると良い
  • OpenALをそのまま使うのはややめんどうなので ALURE を使った上で,更に薄めのラッパーを用意すると良い

OpenAL

マルチプラットフォームなサウンドAPI群です。APIの設計が OpenGL によく似ています。 macOSやiOSにおいては CoreAudio の上位レイヤーの一部としてOpenALのAPIが整備されています。
macOSにおける開発では特に新たなライブラリのインストールの必要はなく, import OpenAL するだけで使えるようです。

ALURE

OpenGLにおける GLUT のような立ち位置のライブラリと紹介されることが多いです。 公式ドキュメント をみるとAPIは少なめで, 敷居は低めに感じました。コンテキストの作成やデバイスオープンなどの処理をまとめてやってくれたり,ファイルのオープンやストリーミング処理をサポートしてくれるAPIが目立ちます。カスタムデコーダを設定することもできるので, デコーダライブラリを使ったデコード処理をコールバックに登録しておけば.mp3.oggなんかも再生できてしまいます。
プロジェクトへの取り込み方は以下のページで解説されています。
ALURE(OpenALユーティリティ)をiOSで使う

補足すると,Bridging-Header.h を新たに追加するか, importを追記する手順が必要だったりします。

また,ALURE1.2において, brew install cmake でインストールした cmake(v3.8.1) でコマンドラインで生成したMakefileだとmakeが途中でこけてしまいます。詳しくないので原因はわかりませんが,GUI版で試すとうまくいきました。

おそらくですが,macOSやiOSで.ogg を読み込めるようにするための一番簡単な実装がOpenAL+ALUREだと思っています。 .ogg 読み込みたい!というモチベーションでも採用に値します。

AVFoundation#AVAudioPlayer相当の実装

AVAudioPlayerのAPIには詳しくないのですが,最低限, 指定したファイルの読み込み再生,ポーズ,停止,再生位置指定 ができれば良いのではないでしょうか。

import Foundation
import OpenAL

final class SoundSource {
  private var buffer: ALuint
  private var source: ALuint
  private let fullFilePath: String

  init?(fullFilePath: String) {
    let buffer = alureCreateBufferFromFile(fullFilePath)

    if buffer == alNone {
      print("Failed to load \(fullFilePath)")
      return nil
    }

    var source: ALuint = 0
    alGenSources(1, &source)

    alSourcei(source, AL_BUFFER, ALint(buffer))

    self.buffer = buffer
    self.source = source
    self.fullFilePath = fullFilePath
  }

  deinit {
    alureStopSource(source, alTrue)
    alDeleteSources(1, &source)
    alDeleteBuffers(1, &buffer)
  }

  // MARK: - SoundPlayer

  func play() {
    if alurePlaySource(source, nil, nil) != alTrue {
      print("Failed to play source \(self.fullFilePath)")
    }
  }

  func stop() {
    if alureStopSource(source, alFalse) != alTrue {
      print("Failed to stop source \(self.fullFilePath)")
    }
  }

  func pause() {
    if alurePauseSource(source) != alTrue {
      print("Failed to pause source \(self.fullFilePath)")
    }
  }

  func setOffset(second: Float) {
    alSourcef(source, AL_SEC_OFFSET, second)
  }

  func setShouldLooping(_ shouldLoop: Bool) {
    alSourcei(source, AL_LOOPING, shouldLoop ? 1 : 0)
  }

  func setVolume(_ value: Float) {
    alSourcef(source, AL_GAIN, value)
  }
}

エラー処理がまだ甘い気がするのでつっこみ歓迎します。

使い方は以下のようにします

let souerce = SoundSource(fullFilePath: "/path/to/file.wav")
souerce?.play()

メモリリークに関する注意点

source 及び buffer の解放は以下の順番で行うこと

deinit {
  alureStopSource(source, alTrue)
  alDeleteSources(1, &source)
  alDeleteBuffers(1, &buffer)
}

以下の順番で解放を試みると,メモリリークします。

deinit {
  alureStopSource(source, alTrue)
  alDeleteBuffers(1, &buffer)
  alDeleteSources(1, &source)
}

所感

タイミングにシビアな音ゲーを開発中なのですが, AVAudioPlayerで済んだら楽だろうなーと軽い気持ちで採用したところ,遅延が酷く,とてもゲームにならなかったので解決策を模索したところOpenALに行き着きました。 公式資料の
CoreAudioの概要 を見ても,ゲームならOpenAL を使うべしと書いてあります。もっと低レベルまで降りていかなければいけないのかと思い気が滅入りそうになりましたが,意外と簡単に遅延問題は解決しました。

おまけ:もう少し実用的な設計

お好きにどうぞ。多分 Swift3 環境でコピペで動きます。

SoundPlayerFactory.swift
import Foundation
import OpenAL

fileprivate let alTrue: ALboolean = Int8(AL_TRUE)
fileprivate let alFalse: ALboolean = Int8(AL_FALSE)
fileprivate let alNone: ALuint = ALuint(AL_NONE)

final class SoundPlayerFactory {

  // This class is Singleton
  // If shared object is nil, try to instantiate because of failable initializer.

  static private var instance = SoundPlayerFactory()

  static var shared: SoundPlayerFactory? {
    if instance == nil {
      instance = SoundPlayerFactory()
    }

    return instance
  }

  private init?() {
    let isInitialized = alureInitDevice(nil, nil)

    guard isInitialized == alTrue else {
      print("Failed to init device")
      return nil
    }
  }

  deinit {
    alureShutdownDevice()
  }

  // For golbal use sounds

  private(set) var player: SoundPlayer<String>?

  // MARK: - internal

  func makePlayer<T>(keyAndFileFullPath: [T: String],
                     onLoaded: @escaping ((SoundPlayer<T>, NSError?) -> Void)) {
    DispatchQueue.global(qos: .background).async {
      var keyAndSources = [T: ConcreteSoundSource]()
      var errorToRead = [String]()

      keyAndFileFullPath.forEach {
        guard let source = ConcreteSoundSource(fullFilePath: $1) else {
          errorToRead.append($1)
          return
        }

        keyAndSources[$0] = source
      }

      let player = SoundPlayer(keyAndSources: keyAndSources)

      var error: NSError?
      if errorToRead.count > 0 {
        error = NSError(domain: "harp.SoundPlayerFactory", code: 0,
                        userInfo: [NSLocalizedDescriptionKey: "Failed to load \(errorToRead.count) files. \(errorToRead)"])
      }

      onLoaded(player, error)
    }
  }

  func makeSharedPlayer(keyAndFileFullPath: [String: String],
                        onLoaded: @escaping ((SoundPlayer<String>, NSError?) -> Void)) {
    makePlayer(keyAndFileFullPath: keyAndFileFullPath) { [weak self] in
      self?.player = $0

      onLoaded($0, $1)
    }
  }
}

// MARK: - fileprivate

fileprivate final class ConcreteSoundSource: SoundSource {
  private var buffer: ALuint
  private var source: ALuint
  private let fullFilePath: String

  init?(fullFilePath: String) {
    let buffer = alureCreateBufferFromFile(fullFilePath)

    if buffer == alNone {
      print("Failed to load \(fullFilePath)")
      return nil
    }

    var source: ALuint = 0
    alGenSources(1, &source)

    alSourcei(source, AL_BUFFER, ALint(buffer))

    self.buffer = buffer
    self.source = source
    self.fullFilePath = fullFilePath
  }

  deinit {
    alureStopSource(source, alTrue)
    alDeleteSources(1, &source)
    alDeleteBuffers(1, &buffer)
  }

  // MARK: - SoundPlayer

  func play() {
    if alurePlaySource(source, nil, nil) != alTrue {
      print("Failed to play source \(self.fullFilePath)")
    }
  }

  func stop() {
    if alureStopSource(source, alFalse) != alTrue {
      print("Failed to stop source \(self.fullFilePath)")
    }
  }

  func pause() {
    if alurePauseSource(source) != alTrue {
      print("Failed to pause source \(self.fullFilePath)")
    }
  }

  func setOffset(second: Float) {
    alSourcef(source, AL_SEC_OFFSET, second)
  }

  func setShouldLooping(_ shouldLoop: Bool) {
    alSourcei(source, AL_LOOPING, shouldLoop ? 1 : 0)
  }

  func setVolume(_ value: Float) {
    alSourcef(source, AL_GAIN, value)
  }
}
SoundPlayer.swift
import Foundation

final class SoundPlayer<T: Hashable> {
  private var sources: [T: SoundSource]

  init(keyAndSources: [T: SoundSource]) {
    sources = keyAndSources
  }

  func play(forKey: T) {
    guard let source = sources[forKey] else {
      return
    }

    source.play()
  }

  func stop(forKey: T) {
    guard let source = sources[forKey] else {
      return
    }

    source.stop()
  }

  func pause(forKey: T) {
    guard let source = sources[forKey] else {
      return
    }

    source.pause()
  }

  func setOffset(second: Float, forKey: T) {
    guard let source = sources[forKey] else {
      return
    }

    source.setOffset(second: second)
  }

  func setShouldLooping(_ shouldLoop: Bool, forKey: T) {
    guard let source = sources[forKey] else {
      return
    }

    source.setShouldLooping(shouldLoop)
  }

  func setVolume(_ value: Float, forKey: T) {
    guard let source = sources[forKey] else {
      return
    }

    source.setVolume(value)
  }
}
SoundSource.swift
import Foundation

// Force to instatinate from factory.
// Concrete implementation is inner SoundPlayerFactory

protocol SoundSource {
  func play()
  func stop()
  func pause()
  func setOffset(second: Float)
  func setShouldLooping(_ shouldLoop: Bool)
  func setVolume(_ value: Float)
}

使い方

var audioPlayer: SoundPlayer<Int>?

let keyAndPath = [1: "Path/to/file.wav", 2: "Path/to/file.wav", 3: "Path/to/file.wav"]

SoundPlayerFactory.shared?.makePlayer(keyAndFileFullPath: keyAndPath) {[weak self] in
  if let error = $0.1 {
    print(error)
  }

  self?.audioPlayer = $0.0
  self?.audioPlayer?.play(forKey: 1)

  print("Waves is loaded")
}
  • 色々考えた結果,シーンごとに都度必要なリソースを確保して,シーンが必要なだけのインスタンスを保持しておき,シーンを去るときにオートリリースさせられる方が小回りが利いてよさそう。
  • 一方共通で使うBGMやSEは常にメモリに置いておきたいよねーという要望もありそうなので,シングルトンに共有のプレイヤーを一個保持しておくことにしました。Factoryがこれを持つのはよくないのでなんとかする。
  • これは作るゲームの内容にも依ると思うので,やりやすい方で管理してください。僕が作っているのは音ゲーでプレイ画面シーンで大量のキー音を読み込むので,プレイが終わったあとの解放処理が面倒臭くない方法を採用しました。
  • 大量のwavを読み込むことを前提にしているので,非同期で読み込むようにしています。