あんスタ！！MusicにおけるCRI ADX2活用事例（後編）

# はじめに

Happy Elements株式会社 カカリアスタジオ Advent Calendar 2020 の 10日目 の記事です。

この記事では「あんさんぶるスターズ！！Music」におけるCRI ADX2の活用事例についてご紹介します。
本日は後編として、CRI ADX2を実際にアプリに組み込む際にプログラム上の設計や実装について工夫した点をご紹介して行きます。

# CRI ADX2を扱う際の課題について

CRI ADX2で音声再生を行うには、いくつかのデータをロードする手順が必要になります。
 
前提として、CRI ADX2では音声再生の一単位を「キュー」と呼び、複数のキューをグループ化した「キューシート」という単位でデータを管理しています。
ビルドを行うと、一つのキューシートに対して1つのACBファイルと、ストリーム再生の音声を含む場合はAWBファイルが生成されます。

名称|内容|
---|---
[ACBファイル](https://game.criware.jp/manual/adx2_tool/jpn/contents/criatom_tools_atomcraft_acb_file.html)|キューの情報と波形データを格納するファイル<br>キューシートごとに生成される
[AWBファイル](https://game.criware.jp/manual/adx2_tool/jpn/contents/criatom_tools_atomcraft_awb_file.html)|キューの波形データを格納するファイル<br>ストリーム再生のマテリアルを含むキューシートのみ生成される

<img title='image.png' alt='image' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97014/d2740373-2b37-17ff-e73d-710e899717df.png' width="800" data-meta='{"width":1916,"height":1322}'>

アプリ上で音声再生を行うには、以下の手順が必要になります。

1. 音声データ（ACB, AWBファイル）を**Web上からストレージへダウンロード**する
2. ストレージから**キューシートをロード**し、キューの情報を取得する
3. キューシートから**キューの波形データをロード**し、再生の準備を行う
4. 再生が終わったら各種リソースの解放を行う

<img title='image.png' alt='image' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97014/801739cb-9f1c-4770-6c8a-2c87cdc1693b.png' width="800" data-meta='{"width":2220,"height":694}'>

実際のコードの流れを愚直に書くと、こんな感じです。（あくまで参考用で、例外制御など細かいものは全て省いています）

```cs
var url = "http://example.com/hoge.acb";
var acbPath = $"{Application.persistentDataPath}/cri/hoge.acb";
var cueSheetName = "Hoge";
var cueName = "Fuga";

// 1. ACBファイルをダウンロード
using (var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET))
{
    var downloadHandlerFile = new DownloadHandlerFile(acbPath);
    request.downloadHandler = downloadHandlerFile;
    await request.SendWebRequest();
}

// 2. キューシートをロード
CriAtom.AddCueSheetAsync(cueSheetName, acbPath, "");

// 3. キューをロード
var player = new CriAtomExPlayer();
var acb = CriAtom.GetAcb(CueSheetHandler.Resource.CueSheetName);
var player.SetCue(acb, cueName);

// 4. 再生
player.Start();

// 5. リソースの解放
player.Dispose(); // キューの解放
CriAtom.RemoveCueSheet(cueSheetName); // キューシートの解放
```

例外制御などを含まない最小限のコードでも、これだけの量になります。実際にはさらにAWBファイルがある場合の考慮、ファイルのキャッシュ機構、ダウンロード進捗通知、細かい再生制御（一時停止、再開etc…）も必要になりますし、うまく共通化しなければコードは容易に複雑化し崩壊の一途を辿っていく事でしょう…

# 音声再生のクラス設計と実装

上記の課題点を踏まえ、ここからはあんスタMusicではどのようにクラス設計・実装していったかを解説していきます。

## ファイルダウンロード＆キャッシュ機構について

CRI ADX2の音声データはAssetBundle化しなくとも利用することが可能です。StreamingAssets内からロードできる他、絶対パス指定で端末上にあるデータをロードしてくることが可能です。
その代わり、Web上からダウンロードしてくる場合、キャッシュ機構は自前で用意する必要があります。

同じCRIミドルウェア群の中の一つとして[ファイルマジックPRO](https://game.criware.jp/manual/unity_plugin/latest/contents/fs4u_userman.html)という物も用意されていますが、こちらはバージョン管理機構が無く、ツールもWindows専用のものしか提供されていないなど、私達の求める要件は満たしていなかった為、自作することになりました。

実装内容としてはここでは詳細には解説しませんが、基本的には[DownloadHandlerFile](https://docs.unity3d.com/ja/current/ScriptReference/Networking.DownloadHandlerFile.html)を利用して[Application.persistentDataPath](https://docs.unity3d.com/ja/current/ScriptReference/Application-persistentDataPath.html)以下にダウンロードしてくるだけです。

その他、Web上のデータだけでなくStreamingAssetsのデータも同じように扱えるようにしたり、AWBファイルの有無の差を吸収するために、以下のようなインターフェースにしました。

- ICriAudioEntity: キューシートのデータの所在を表す構造体
  - `bool IsRemoteResource`: Web上のデータかどうか
  - `string CueSheetName`: キューシート名
  - `bool UseAwb`: AWBファイルを用いるかどうか
- AudioResourceManager: キューシートのDL・キャッシュを管理するクラス
  - `async UniTask<CriAudioResource> PrepareAudioResource(ICriAudioEntity entity)`:
- CriAudioResource: キューシートのデータの端末上の所在を表す構造体
  - `string CueSheetName`: キューシート名
  - `string acbPath`: 端末上のacbファイルのパス
  - `string awbPath`: 端末上のawbファイルのパス
  - `async UniTask<CriCueSheetHandler> LoadCueSheetAsync()`: キューシートを読み込みCriCueSheetHandler（後述）を取得する

```cs
var entity = new CriAudioEntity(
    cueSheetName: "hoge/fuga",
    isRemoteResource: true,
    useAwb: false);
var audioResource = await AudioResourceManager.Instance.PrepareAudioResource(entity);
```

URLやダウンロード先のパスの解決などは全てAudioResourceManager内で完結させる事で、外部からは細かいことを意識せず、必要最小限の情報だけでリソースを準備できるようになっています。

## 再生制御について

CRI ADX2の音声をUnity上で再生するには、[CriAtomSource](https://game.criware.jp/manual/unity_plugin/latest/contents/classCriAtomSource.html)と[CriAtomExPlayer](https://game.criware.jp/manual/unity_plugin/latest/contents/classCriAtomExPlayer.html)という2つのクラスが用意されています。
それぞれ以下のような特徴があります。

- CriAtomSource
    - MonoBehaviourを継承したクラス
    - インスペクタ上で簡単に再生設定が行える
    - **再生に必要な処理をある程度自動でやってくれる**
        - GameObjectの破棄と同時にリソースを破棄してくれるなど
    - 内部ではCriAtomExPlayerを操作している
- CriAtomExPlayer
    - ネイティブプラグインのラッパークラス
    - CriAtomSourceよりも**低レベルな操作**ができる
    - **細かい制御については自前で行わなければならない**

CriAtomSourceはある程度勝手に必要な処理をやってくれる反面、細かい制御には向いていないです。また、GameObjectのコンポーネントとして動くため、パフォーマンス面では**オーバーヘッド**があります。
内部ではCriAtomExPlayerを操作しているため、CriAtomSourceでできることは基本的に全てCriAtomExPlayerでできると言えます。

そのため、「あんスタ！！Music」では全てのコードで**CriAtomExPlayerのみを使用**しています。

しかし、CriAtomExPlayerを使うことでより細かく柔軟な制御が可能になる一方で、キューシート・キューのロード状況管理などは自前で行う必要があります。

## キューシートの管理を徹底する仕組み

キューシートはCriAtomクラスで中央集権的に管理されています。

```cs
// キューシートのロード
CriAtom.AddCueSheetAsync(cueSheetName, acbPath, awbPath);

// 再生処理
DoSomething();

// キューシートの解放
CriAtom.RemoveCueSheet(cueSheetName);
```

愚直にAddCueSheetAsync、RemoveCueSheetを呼んでいるだけだと、まず間違いなく解放漏れが起きる事でしょう。そこで、解放漏れが起こらないような設計にする事が大事です。
あんスタMusicではキューシートのロード状況を制御するためのクラスを用意しました。

- CriCueSheetHandler: キューシートをメモリ上にロード・アンロードするのを制御するクラス
  - `async UniTask PrepareAsync()`: キューシートをメモリ上にロードする
  - `CriCueHandler GetCueHandler(string cueName)`: CriCueHandlerを取得し、キューをロードする
  - `void Dispose()`: キューシートをアンロードする
- CriCueHandler: キューのロード・再生周りを制御するクラス
  - `void Play()`: 再生を開始する
  - `void Stop()`: 再生を停止する
  - `void Dispose()`: キューをアンロードする

**IDisposable**を実装していることがポイントで、利用が終わったらDisposeを呼ぶことで利用者にリソースの解放を義務付けています。

```cs
// データの準備（ダウンロード）
var audioResource = await AudioResourceManager.Instance.PrepareAudioResource(entity);
// キューシートのロード
var cueSheetHandler = await audioResource.LoadCueSheetAsync();
// キューのロード
var cueHandler = cueSheetHandler.LoadCueHandler(cueName);
// 再生
cueHandler.Play();
await cueHandler.WaitForPlayEnd();
// アンロード
cueHandler.Dispose();
cueSheetHandler.Dispose();
```

まだ少し冗長に見えますが、これで大分リソースの管理はしやすくなったように見えるのでは無いでしょうか？
再生制御用のオブジェクトをDisposeすればそのままリソースが解放されるというのは直感的で分かりやすいのかなと思っています。
実際には単純に再生するだけならもう少し短く済むような拡張メソッドも用意しています。

## リソース解放漏れのチェックについて

とは言えDisposeを呼び忘れてしまったら元も子もないので、最終的に解放漏れが無いかをチェックするには、UnityEditor上でCriAtomコンポーネントのInspectorをチェックします。

<img title='image.png' alt='image' src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97014/323a7e63-9de0-625c-fe5e-d0eab715b321.png' width="600" data-meta='{"width":1048,"height":674}'>

現在ロードされているキューシートの一覧が見られるため、解放漏れや多重ロードのチェックに有効です。

# 音声とタイミングの同期について

リズムゲームにおいては音声とのタイミングの同期が要となります。
あんスタMusicでは、リズムゲーム中には以下の3つの時間概念が存在します。

- ゲーム内時間（譜面時間）
- 音声再生時間
- 3Dライブ再生時間

## ゲーム内時間（譜面時間）

ゲーム内時間（譜面時間）は譜面の描画や判定処理に用いる時間です。
毎フレーム[Time.deltaTime](https://docs.unity3d.com/ja/current/ScriptReference/Time-deltaTime.html)を加算して計算した合計から判定タイミング調整の設定値を差し引いたものが最終的なゲーム内時間となります。

基本的に後述する音声再生時間と同じ値を示しますが、再生時間はリアルタイムで更新されるのに対し、こちらは**フレーム単位で更新**されます。

入力検知に用いている[Input Manager](https://docs.unity3d.com/ja/2019.4/Manual/class-InputManager.html)がフレームレートに依存しているため、判定処理も同じくフレームレートに依存した処理にしています。
フレームレートに依存しない入力検知が可能になる[Input System](https://docs.unity3d.com/ja/2019.4/Manual/com.unity.inputsystem.html)を使うなら、音声再生時間を直接利用しても良いかもしれません。

ゲーム内時間カウントの開始タイミングには注意が必要です。
ストリーム再生の音声を再生する場合には、再生のために音声データのバッファリングが必要なため、そのラグも考慮してカウントを開始しないと、ゲーム内時間と音声再生時間にズレが生じる場合があります。

```cs
var playBack = player.Prepare();
await UniTask.WaitUntil(() => playback.GetStatus() == CriAtomExPlayback.Status.Playing);
playback.Resume(CriAtomEx.ResumeMode.PreparedPlayback);

StartGame(); // カウント開始
```

## 音声再生時間

ゲーム内時間とは別に、実際に音声を再生している時間を取得できます。処理落ちなど様々な要因によってゲーム内時間と音声再生時間はズレることがあるので、**ズレが一定以上になった時に時間を補正する**処理を入れています。

リズムゲーム中の再生時間の取得には、通常よりも厳密な再生時間が取得できる[CriAtomExPlayback.GetTimeSyncedWithAudio](https://game.criware.jp/manual/unity_plugin/latest/contents/structCriAtomExPlayback.html#a7749a6e90df0fb5603308fa046ff2598)を使っています。
通常の再生時間の取得を用いた場合、Androidで段々BGMとノーツのタイミングがズレてくるなどの問題がありました。

補正処理は3ms以上ズレていればゲーム内時間を音声再生時間に合わせ、200ms以上ズレていれば音声再生時間をゲーム内時間に合わせるようにしています。
この値は比較的最近（発売2〜3年以内のミドル〜ハイスペック帯）の端末で快適にプレイできるように調整した結果です。

## 3Dライブ再生時間

あんスタMusicの3DライブのアニメーションはUnityのTimelineで動いています。そのため、TimelineのPlayableDirectorから取得できる時間が再生時間となります。
Timelineの時間は勝手に進行していくので計算の必要はありませんが、ゲーム内時間とのズレが大きくなった場合は3Dライブ側の再生時間を補正するようにしています。

# 後編まとめ

- ファイルのキャッシュ機構は自分で作る
- キューシートのロード制御のためにIDisposableなクラスを用意する
- 再生にはCriAtomSourceではなくCriAtomExPlayerを使う
- リズムゲーム中の音声再生時間の取得にはCriAtomExPlayback.GetTimeSyncedWithAudioを使う
  - Unity上での時間（`Time.deltaTime`）と音声再生時間はズレが起きる場合があるため、適宜補正処理が必要

後編ではかなり具体的な実装内容にまで踏み込んで解説してみましたが、いかがでしたでしょうか。
皆さんのアプリ開発に少しでも参考になれば幸いです。

# メンバー募集

Happy Elements株式会社 カカリアスタジオでは、
いっしょに【熱狂的に愛されるコンテンツ】をつくっていただけるメンバーを大募集中です！

もし弊社にご興味持っていただけましたら、是非一度
下記採用サイトをご覧ください。
[Happy Elements株式会社 採用特設サイト](https://recruit.happyelements.co.jp/)