🏗️

超低ビットレートな音声コーデックLyraをBazel+EmscriptenでWasmにビルドして動かす

2023/08/22に公開

はじめに

LyraというGoogleの開発した低ビットレートの音声コーデックがあります。以前の記事ではlyra-jsを用いてLyraをWebRTCのP2Pで利用できるようにしました。
今回は自身でLyraをWebAssemblyのライブラリへとビルドしてlyra-jsの代わりに動かしてみました。読者が自身でBazelのプロジェクトをWebAssemblyとして動かす際に参考になればと思います。
実装したリポジトリはこちらで、適宜このコードを参照します。

https://github.com/google/lyra
https://zenn.dev/y_i/articles/lyra-js-over-webrtc-p2p
https://github.com/y-i/lyra-wasm/

この記事が向いている人

Bazelを使っているC/C++のコードをWasmにして動かしたい場合、Bazelの設定やEmscripten向けコードで流用できる情報があると思います。
WebAssemblyとは何か、C++やJavaScriptの記法などについては説明しないので、それらの前提知識がある方向けの記事です。

動機

lyra-jsはlyraをWasmにビルドし、JavaScriptから利用できるようにしたライブラリです。
LyraのAPIを見ると、エンコーダーでいくつかの項目が設定可能なのが確認できます。しかし、lyra-jsではそれらの設定を変更することはできません。例えばビットレートは本来のLyraでは3.2kbps/6kbps/9.2kbpsから選べるのですが、lyra-jsでは3.2kbpsでしか使うことができません。
そこで、それらのオプションを変更して使ってみたり、BazelとEmscriptenでのビルドの知見を得たりするために自身でLyraのC++のソースコードをWasmへビルドしてみました。

実装

実装概要

LyraはBazelを用いてビルドするように構成されています。
そのため、まずはBazel側でEmscriptenを利用するようにし、最終的にWasmとして出力できるように設定する必要があります。[1]
ビルドができることが確認できたら、次はJavaScript側からC++の関数が利用できるようにするためにC++側でEmscripten向けのコードを書いてJavaScript用のAPIを準備する必要があります。
最後に、今回ビルドしたLyraを、前回書いたlyra-jsを使用したJavaScriptのコードから呼び出すように修正しました。

結果として、9.2kbpsでは想定通り動作するものの、6kbpsや3.2kbpsでは思った通りに動作しないライブラリが出来上がりました。[2]
なお手元のMacBook Proではビルドに30分以上かかりました。GCEのn2-standard-8のVMでは5分ほどで終わるので、そちらの方がおすすめです。

https://bazel.build/

ライブラリとバージョン

今回の実装で利用したバージョンは以下のとおりです。

ライブラリ バージョン
Lyra 1.3.2
Bazel 5.3.2
Emscripten SDK 3.1.23

Bazelはビルドツールの一種でLyraで採用されています。同様のものにMakeやCMakeなどがあります。
EmscriptenはCやC++をWebAssemblyにコンパイルするコンパイラでCLIではemcc src.cppのように利用できます。その中でEmscripten SDKはEmscriptenの管理ができるSDKです。
今回はコンパイラを直接叩くのではなく、Bazelでビルドする中にEmscriptenでWasmとして出力する設定を追加、という流れになっています。

Bazelの設定

BazelとEmscriptenを用いた過去の手法を調べていたところ、こちらのブログを発見しました。
このブログではBazelを使ったサンプルをEmscriptenでWasm化する方法を紹介しており、Bazelの設定ファイル内でEmscriptenを利用し、Wasmとして出力するにはどうすれば良いかがとても分かりやすく記されていました。

https://medium.com/@s0l0ist/c-to-webassembly-using-bazel-and-emscripten-ae797c119bef

今回のビルドでもそれらの設定ファイルをベースとして設定しています。
元のLyraのリポジトリはsubmoduleとして取得し、WORKSPACE内でlocal_repositoryとして追加しています。

WORKSPACE
local_repository(
    name = "lyra",
    path = "./lyra",
)

.bazelrc.bazelversionは元のLyraのリポジトリの同名設定ファイルへのシンボリックリンクにすることで多重管理を防いでいます。
WORKSPACEファイルも同様の方法で管理したかったのですが、他のWORKSPACEファイルを読み込むような機能が無いのと、Emscripten用に設定を追記する必要があったため、元の設定ファイルをコピーし、そこにEmscripten用設定を追記する形にしました。[3]

BUILDファイルはブログ記事のBUILDファイルをベースにしていますが、一部の設定によってビルドに失敗することがあったので一部無効化しています。[4]
また、モデルファイルを事前に読み込むために、pre.js関係のオプションを追加しています。
具体的にはlinkpotsに--pre-js $(location :pre.js)を、cc_binaryのadditional_linker_inputsに"pre.js"を追加しています。pre.jsの中身については後述します。
このままではビルド時にエラーが出てしまい困っていたのですが、こちらのBUILDファイルを参考にオプションを追加したところ無事にビルドすることができるようになりました。

C++側でのAPIのエクスポート

ビルドの設定が完了したので、JavaScript側から呼べるようにWasmでAPIとして提供するものをC++側で定義する必要があります。
"Emscripten export"などのキーワードで検索するとEmscriptenのページが引っ掛かります。
こちらではccall()cwrap()EMSCRIPTEN_KEEPALIVEなどを使った手法が説明されています。
しかし、参考にしたブログではEmbindEMSCRIPTEN_BINDINGS()を使っており、こちらの方が使いやすそうなため、こちらの手法を紹介します。
これを用いるには以下のコードが必要です。my_moduleの部分はどんな文字列でも影響無いです。

glue.cpp
#include <emscripten/bind.h>

using namespace emscripten; // 任意

EMSCRIPTEN_BINDINGS(my_module) {
    // ...
}

EMSCRIPTEN_BINDINGSの中にAPIを定義していきます。今回使いたいのはLyraEncoderLyraDecoderのクラスなのでこれらを使えるようにします。
基本的な使い方はEmbindのページを参照していただき、ここでは工夫や注意が必要だった点を説明します。

まず、Encoder/Decoderを作成するためのcreate関数についてです。[5]この関数は引数にモデルファイルの存在するディレクトリを取ります(ファイルのパスでは無いので注意)。今回はEmscriptenのFile System APIを使ってファイルを配置するため、このディレクトリを任意に設定できる必要がありません。そのため、optional_overrideで処理を上書きし、こちらで指定したパスを渡すようにしています。

glue.cpp
      .class_function(
          "create", optional_override([](int sample_rate_hz, int num_channels) {
            return LyraDecoder::Create(sample_rate_hz, num_channels,"/model_path");
          }))

次にEncoderのencode、DecoderのsetEncodedPacket関数です。[6]これらの関数はJavaScript側では引数にArrayBufferを渡して利用する関数で、C++側ではstd::vectorとして受け取りたいです。[7]
一見両方とも配列のような型なので、C++側で引数の型をstd::vectorにしておけば自動で変換してくれると思うかもしれませんが、実際には実行時にエラーが発生します。
Emscriptenに元々存在する変換を見ると、std::vectorへの変換は存在しないため、C++側ではemscripten::valとして受け取り、変換する処理を自身で書く必要があります。
ちょうど便利なvecFromJsArrayという関数があるため、これにemscripten::valを渡すだけでstd::vectorに変換できます。

glue.cpp
      .function("encode", optional_override([](LyraEncoder &self, val v) {
                  std::vector<int16_t> samples = vecFromJSArray<int16_t>(v);

C++からJavaScriptに値を返す時は、Uint8Arrayであれば元のstd::vectorの変数dataに対してreturn val(typed_memory_view(data.size(), data.data()));とするだけでUint8Arrayが返ります。
一方、それ以外のTypedArrayを返すにはもう一手間必要です。まずC++側でその型用の変数を定義します。その後上と同様にval()を使用した後、定義したTypedArrayのメソッドを利用して値を入れる必要があります。関連するコードを抽出すると以下のようになります。これでJavaScript側でInt16Arrayを受け取ることができます。

auto arrayBuffer = val::global("Int16Array").new_(num_samples);

auto view = val(typed_memory_view(data.size(), data.data()));

arrayBuffer.call<void>("set", view, num_samples);

return arrayBuffer;

JavaScript側での対応

C++側でJavaScriptの型との間での変換に必要な処理をすべて書いているため、JavaScriptから今回作成したライブラリの関数を使う際に裏がWasmであるか、C++製であるかといったことを気にする必要はありません。
しかし、Lyraの利用するモデルを初期化時に渡すように設定しているため、その部分はJavaScript側でファイルを取得し、中身を引数として渡すというのが必要になります。

まず、pre.jsではEmscriptenのFile System APIを用いてモデルファイルを追加する処理を書いています。
これにはModule.preRunを利用し、その中で、モデルファイル用のディレクトリの作成と、ファイルへの書き込みを行います。Lyraでは以下の4つのファイルが必要で、これらが無いとEncoder/Decoderの初期化時にファイルが無いというエラーが返ってきます。
実際のデータは初期化時にオプションとして渡すことになります。

pre.js
Module['preRun'] = () => {
    FS.mkdir('/model_path');
    FS.writeFile('/model_path/quantizer.tflite', Module['ModelData']['quantizer']);
    FS.writeFile('/model_path/lyragan.tflite', Module['ModelData']['lyragan']);
    FS.writeFile('/model_path/soundstream_encoder.tflite', Module['ModelData']['soundstream']);
    FS.writeFile('/model_path/lyra_config.binarypb', Module['ModelData']['lyra_config']);
};

以下が初期化時のコードになります。[8]
オプションの内、ModelDataではfetchしてきたデータをUint8Arrayとして渡しています。
locateFileでは.wasmファイルの読み込み時にのみパスを変更する処理を行っています。これはEmscriptenによって出力されたラッパーのJavaScriptにおいてWasmファイルのパスがbazel-out/以下になってしまっているので、今回作成したbuild-outputs/以下から読み込めるように変更しています。
また、LyraEncoderとLyraDecoderは別々に初期化しないとノイズしか聞こえない状態になります。この理由については調査できていません。

lyra.js
const quantizerModelFile = await fetch('./model/quantizer.tflite').then(res => res.arrayBuffer());
const lyraganModelFile = await fetch('./model/lyragan.tflite').then(res => res.arrayBuffer());
const soundstreamModelFile = await fetch('./model/soundstream_encoder.tflite').then(res => res.arrayBuffer());
const lyraConfigModelFile = await fetch('./model/lyra_config.binarypb').then(res => res.arrayBuffer());

const lyraEncoderInner =  await lyraJs.default({
    locateFile: (path, scriptDirectory) => {
        // 絶対パスで埋め込まれているので修正
        if (path.endsWith('.wasm')) return lyraPath + path;

        return scriptDirectory + path;
    },
    ModelData: {
        quantizer: new Uint8Array(quantizerModelFile),
        lyragan: new Uint8Array(lyraganModelFile),
        soundstream: new Uint8Array(soundstreamModelFile),
        lyra_config: new Uint8Array(lyraConfigModelFile),
    },
}).then(async ({ready, LyraEncoder} )=> {
    await ready;
    return LyraEncoder;
});

品質とトラフィックの検証

Lyraが音声向けなので、音声とそれ以外の例として音楽のファイルについてそれぞれ以下の設定で比較しました。
実装の章で書いた通り9.2kbps以外で動作しないため、6kbpsで検証はしておらず、3.2kbpsはlyra-jsでの結果です。

  • 3.2kbps
  • 9.2kbps (DTX無効)
  • 9.2kbps (DTX有効)

元データの詳細などは以前の記事を確認ください。

品質の比較

音声

Lyraの3.2kbpsではオリジナルと比較して劣化しているのがわかりましたが、9.2kbpsではほとんど劣化が感じられず高品質になったといえます。Opusでは32kbpsなので、品質をあまり下げずにトラフィックを削減できそうです
DTXの有無については聞こえ方には影響が無いようになっていそうです。

音楽

Lyraの9.2kbpsは3.2kbpsの場合と比べると高品質になっていると感じました。しかし、音声の場合とは違い、オリジナルと比べると差があるのが明確に分かりました。
DTXの有無については聞こえ方には影響が無いようになっていそうです。
音声向けコーデックとのことなので、音楽ではビットレートを上げても限界があるのかもしれません。

トラフィックの比較

chrome://webrtc-internalsで見ることができるグラフを用いて実際に想定したトラフィックになっているかを確認しました。
下のグラフのとおり、おおよそ指定したビットレートになっていることが確認できます。
DTXについては音声については部分的にトラフィックが削減され効果が期待できますが、音楽の方では効果はありませんでした。今回の音楽のファイルは無音区間が無いためこのような結果になったと考えられます。


6kbpsを指定した場合(DTX無効)

9.2kbpsを指定した場合(DTX無効)

音声で9.2kbpsを指定した場合(DTX有効)

音楽で9.2kbpsを指定した場合(DTX有効)

まとめ

Lyra自体の性能の検証については、9.2kbpsでしか動かなかったものの3.2kbpsとの品質の違いを体感したり、DTXによる帯域削減を確認したりすることができました。

ビルドについては、あまりLyra固有の方法にならないような手法でBazel+EmscriptenによるWasm化を実現できました。
今後他のBazelが用いられているライブラリについても同様の方法でWasm化してブラウザ上などで動かせるようにする際に参考になればと思います。

脚注
  1. 依存関係などを完璧に理解すればBazelを剥がせるかもしれませんが、よほどのことがない限りやる理由がないと思います ↩︎

  2. 9.2kbps以外だと変な音が入ったりノイズのみになったりします ↩︎

  3. ついでに別プラットフォーム向けの設定をコメントアウトしています ↩︎

  4. 例えばclosure compilerを有効化するとlinkが終わらなくなり、emmallocにするとノイズのみになります。ALLOW_MEMORY_GROWTHはどちらでも構いません。 ↩︎

  5. https://github.com/y-i/lyra-wasm/blob/master/js/cpp/glue.cpp#L14-L20 ↩︎

  6. https://github.com/y-i/lyra-wasm/blob/master/js/cpp/glue.cpp#L21-L35 ↩︎

  7. インスタンスメソッドをoptional_ovverrideで定義する場合、第一引数はそのクラスの型にする必要があります ↩︎

  8. https://github.com/y-i/lyra-wasm/blob/master/example/src/lyra.js ↩︎

Discussion