Unityに最適化した音声デコードライブラリを自作する上で工夫したこと

こんにちは、エンジニアリングマネージャーの渡辺(@mochi_neko_7)です。

今回はUnityで音声データをランタイムでデコード/エンコードするライブラリを作った話を紹介します。

先日、OpenAIのChatGPTWhisperをWebAPIで利用できるようにする話を記事で紹介しました。

synamon.hatenablog.com

この延長として、ChatGPTで生成した会話をVOICEVOXCOEIROINKKoeiromapなどの音声合成サービスに渡して音声化をしていました。

こういったUnityの外側との音声データのやり取りをする際には、どうしてもランタイムでの音声データの変換処理をする必要が出てきます。

その際に手軽に利用できるライブラリを趣味で開発しました。

github.com

こちらの紹介を簡単にしつつ、Unityにおける実装の工夫や使用リソースの最適化などのポイントに関しても紹介します。

メインスレッドの処理負荷、メモリ負荷をそれなりに抑えるよう作っているため、手軽に利用しやすいのではと思っています

基本的な内容ではありますが、重い処理をするライブラリをUnity向けに作成する際にも参考になれば幸いです。

想定しているユースケースと背景

例えば音声合成だと、テキストから音声データを生成し、WebAPIで特定のフォーマットのバイナリーデータとして受け取り、それをデコードして波形のサンプルデータにしてからAudioClipにデータをセットして利用します。

Whisperなどの音声認識だと逆で、Unityで録音した音声データを特定のフォーマットにエンコードして、WebAPIにアップロードをしてテキスト化を行います。

データの流れとしては下記のようになります。

Unity ⇆ AudioClip ⇆ Audio Codec (WAVなど) ⇆ Binary ⇆ 外部サービス

しばらく前にUnityでの画像のデコードの記事も書きましたが、Unityは標準では画像や音声、動画などのメディアファイルのエンコード/デコードはサポートしていない or 使いづらいです。

かといって音声ファイルの処理のためだけに大きな音声系ライブラリを導入するのも少し重たいですよね。

UPMでサッと導入でき、Unityに最適化されていて取り回しの良いライブラリが欲しいと思い、自作することにしました。

github.com

現在はすぐに使用したかったWAVのデコード/エンコードのみ対応していますが、後日MP3の対応も追加するつもりです。

実際に使ってみたい方はREADMEに書かれているPackage参照をmanifest.jsonに追加してください。

使い方

使い方はシンプルなので、サンプルテストコードを見ていただければ分かると思いますが、簡単に説明します。

WAVファイルのデコードをしたい場合には、データの Stream とファイル名、それから CancellationTokenWaveDecoder.DecodeByBlockAsync に渡して await すると、デコードされた音声データが AudioClip に加工されて取得できる、といった形です。

audioClip = await WaveDecoder.DecodeByBlockAsync(
    stream,
    fileName,
    cancellationToken);

WAVファイルのエンコードの場合は、エンコード結果を書き込みたい Stream を用意して、エンコードしたい音声の AudioClipCancellationToken と共に WaveEncoder.EncodeByBlockAsync に渡して await する、という形です。

await WaveEncoder.EncodeByBlockAsync(
    outStream,
    audioClip,
    cancellationToken);

例えばファイルに書き込みたい場合はWriteModeの FileStream を指定すればよいですし、WebAPIでPOSTするなど他で使いたい場合は MemoryStream でもよいです。

こちらこちらでは実際に使用もしていますので参考になるかも知れません。

利用している3rd Partyライブラリ

自作したといっても0から作成したわけではありません。

C#(.NET)にはNAudioという有名な音声ライブラリがあるため、これを利用します。

サンプルを眺めると様々なコーデックに対応した汎用的なAPIもあるように見えますが、Core以外ではMediaFoundationを利用する実装も含まれていて、それだとWindowsでしか動かなくなってしまいます。

そのため、WAVやMP3といったよく使うコーデックはちゃんとサポートしているNAudio.Core部分のみ利用し、コーデック別の対応を自分で行うことにします。

ちなみに対応しているWAVファイルは今回はLinear PCM、bit数は16bit、24bit、32bit、IEEE floatのみで、μ-law、a-lawにはまだ対応していませんのでご注意ください(あまり使用しないとは思いますが)。

また、下記で紹介するマルチスレッドの最適化の都合でUniTaskも利用しています。

実装上の工夫

Unityで音声データなどのメディアファイルを取り扱う上で気をつけたいことはまず2点あります。

  1. 処理負荷:デコード/エンコードには通常それなりの処理時間がかかること
    • 短い音声ファイルでもコーデックによってはそれなりに、長い音声ファイルでは言うまでもなく
  2. メモリ負荷:デコード/エンコード時にはメモリを食いやすいこと
    • 大きいデータを愚直に配列として変換しているとメモリ消費が大きくなりやすいため、なるべく効率よく処理をしたい

処理負荷対策

1の処理負荷対策としては、なるべくThread Poolでデコード/エンコード処理を実行して、UnityのMain Threadでの処理負荷を減らす、というのが基本となります。

Main Threadでの処理が重いと、UnityのUpdateの処理が詰まってしまいFPSの低下に繋がります。

アプリの動作がカクつくとユーザー体験の質が下がりますし、VRなどではVR酔いにも繋がるため可能な限りケアしておきたいポイントになります。

NAudioを使ってデコード/エンコード処理自体はPure C#で書かれているためそのままThread Poolに投げれば良いだけですが、AudioClipなどUnity上のオブジェクトとのデータのやり取りをする部分はMain Threadでしか実行できません。

UniTaskではThreadを明示的に切り替えるAPIが用意されているため、これを利用します。

// Main Thread

await UniTask.SwitchToThreadPool();

// Thread Pool
// 思いデコード処理をここでやる

await UniTask.SwitchToMainThread(cancellationToken);

// Main Thread
// AudioClipなどの操作はここでやる

このようなThreadの切り替えをして重い処理をなるべくThread Poolで処理することで、Main Threadでの処理負荷を最適化します。

メモリ負荷対策

次に2のメモリ負荷の対策も考えます。

メモリも言うまでもなくなるべく使用量が小さい方が良いですし、特にAndroid/iOSなどのモバイル端末で動かす場合には使用できるメモリの制約も大きいため、こちらもなるべくケアしておきたいポイントになります。

例えば下記の音声データがあると仮定します。

  • WAV
  • 5MB
  • 16bit PCM
  • 44100Hz
  • ステレオ(2ch)

このデータを扱うときにどれくらいのメモリ消費があるのか計算してみましょう。

  1. サンプルあたりのbyte数は16bit PCMなので16 bits = 2 bytes
  2. 5 MB = 5 * 10242 bytesをWAVのヘッダーの部分は無視してざっくり5 * 10242 / 2 サンプル数
  3. サンプルは32 bit (4bytes) floatに変換するので、(5 * 10242 / 2) * 4 = 10 * 10242 = 10 MBの配列のメモリが必要
  4. バイナリーデータ本体、デコードしたfloat配列、AudioClip側のデータがあるので、合計 5 + 10 * 2 = 25 MB

おおよそ元のデータの5倍とそれなりの大きさになります。

ちなみにこのサイズだとざっくり30秒くらいの音声でしょうか。*1

ではどこが改善できるのかという話ですが、大本のデータはStreamにしておけば呼び出し側で一定改善できますが、AudioClipの部分はUnityの圧縮形式がランタイムでは使用できないことを考えると改善は大変そうです。

一方、まとめてデコードするのではなく一定の長さのfloat配列をバッファとして利用し、少しずつデコードしてAudioClipに書き込むという手法を取れば、中間のサンプルデータで使用するメモリ量を改善できる余地があります。

音声データの単位はコーデックによって異なりますが、WAVはサンプル×チャネル数が最小単位で、NAudionにはこれを読み出すAPIが用意されています。

github.com

ただし、この最小単位毎にデコード→AudioClip書き込みをしようとするとサンプルの数自体がとても多いので、Threadの切り替えの処理がオーバーヘッドとなってしまいます。

そのため、一定サイズのバッファのサンプル分をまとめてデコードすることを繰り返す方法を取ることで、メモリ負荷をバッファ分に抑えつつ、Thread切り替えの回数を減らす、といった対策を取ることができます。

実際の実装はこちらで、呼び出し側で用意した一定サイズのサンプル = Blockを使い回しているのがわかると思います。

こういった工夫を取ることで、メモリ負荷と処理負荷のバランスを取ることができます。

まとめ

以上の実装上の工夫をまとめると下記になります。

  1. 重いデコード/エンコード処理はなるべくThread Poolで実行するようにして、Main Threadへの処理負荷を抑える
  2. 変換における中間データはバッファを使い回すようにしてメモリ負荷を抑える
  3. 音声データはサンプルの塊ごとに処理を行うことで、Thread切り替えの処理負荷とメモリ負荷のバランスを取る

上記の説明はWAVのデコードを例に取りましたが、エンコードする際もほぼ同様です。

工夫の観点自体は基本的な内容ですので、他のUnity向けのライブラリなどを作成される際にも参考になればと思います。

今後の発展

まだ直近欲しかったWAVのデコード/エンコードしか実装していませんが、NLayerを使えばMP3のデコードも比較的簡単に実装できますので、今後対応したいと思っています。

MP3のエンコードはまだ試したことがないので調べるところからになります。

μ-lawやa-lawのWAVもNAudioに対応があるので対応できるとは思いますが、需要はあるんですかね?

また、今回は手軽さからNAudioを採用しましたが、パフォーマンスだけを考えればC++やRustなどで書いたネイティブプラグインを導入する方が良いでしょう。

対応プラットフォームをちゃんと考える必要があるため少し腰が重いですが、ChatGPTを利用したリアルタイムな対話をする上ではいずれレイテンシが気になって対応をするかもしれません。

おわりに

今回はシンプルなWAVファイルのデコードを中心に話をしましたが、ライブラリ開発の全体の流れをざっと見返すと、

  1. WAVフォーマットへの対応部分はNAudioに任せる
  2. Threadの取り扱いはUniTaskを利用する
  3. その上でUnityで使用するにあたっての処理負荷・メモリ負荷を抑えるためのちょっとした工夫を入れる
  4. 最終的にはStream ⇆ AudioClipで変換できるUnityで利用しやすいAPIにして提供する

といった形になります。

やっていること自体はそれほど難しい内容ではありませんが、取り回しの良い小さいライブラリができると自分でも再利用しやすいですし、もしかしたら他の方にも利用してもらえるかも知れません。

自分が使いたいというモチベーションで開発したライブラリではありますが、もし他にも使ってくださる方がいたらフィードバックや要望なども大歓迎です!

*1:5 * 10242 / 2 / 2(ch) / 44100(Hz) ~= 30s