ブラウザーでオーディオのストリーミング再生をする (C++ / Emscripten / OpenAL)


はじめに

先日 FM 音源ドライバーである "MUCOM88" をブラウザーで動作させることをやってみました。

今回はそこで実装したオーディオのストリーミング再生の実装を解説します。 MUCOM88 on Web ではいろいろ試行錯誤した結果、 Emscripten + OpenAL で実装しています。

本記事の最終目的としてはオーディオストリーミング再生ですが、内容としては Embind を使用した JavaScript から C/C++ コードへの Binding についても参考になるのではないかと思います。

Emscripten 環境の準備についてはこちらの記事も参照してください。

WebAudio の API は Google が公開しているサンプルリポジトリが非常に参考になります。


サンプルプログラムの試し方

今回の記事用に簡単なサンプルを書きました。

リポジトリを GitHub からチェックアウトしてきてください。

自分自身は CLion + CMake で作業していますが、ソースコードが単一なので emsdk を使える状態にして下記コマンドでコンパイルできます。


emcc --std=c++17 --post-js EmPost.js --bind -s WASM=1 -o2 -o WebAudioOpenAL.js main.cpp


コンパイル後、適当な Web サーバー経由で index.html を Chrome 等のブラウザで開いてください。


OpenAL とは

OpenAL は C 言語用のクロスプラットフォームの Audio API です。出自自体は OpenGL と無関係です。 API 自体は OpenGL に似せてあって OpenGL を触ったことがあると理解が早いのではないかと思います。

Emscripten における OpenAL はオープンソース版である 1.1 の実装です (2.0 系はプロプライエタリのようです) 。


OpenAL でストリーミング再生処理を実装する

サンプルプログラムと記述は異なりますが、流れ的には同じようにしています。


初期化~再生開始

この部分はプラットフォームに依存していないと思うので、ざっと説明します。


C

ALCdevice *device = nullptr;

ALCcontext *context = nullptr;
ALuint buffers[BufferCount];
ALuint source;
bool isPlaying = false;

device = alcOpenDevice(nullptr);
context = alcCreateContext(device, nullptr);
alcMakeContextCurrent(context);

alGetError();


device と context の生成をし、 context の設定をします。 alcMakeContextCurrent をした後に一度 alGetError をしてエラーコードをクリアする必要があるようです。


C

alGenBuffers(2, buffers);

alGenSources(1, &source);
alSourcef(source, AL_GAIN, 1);
alSource3f(source, AL_POSITION, 0, 0, 0);

for (auto buf : buffers)
{
InternalProcess(buf);
}
alSourcePlay(source);
isPlaying = true;


Buffer を 2 つと Source を作ります。音声のデーターを設定した Buffer を Source に設定し、 Source に対して alSourcePlay で再生開始を指示します。


C

void InternalProcess(ALuint buffer)

{
static const size_t BufLen = 16000;
short buf[BufLen];

// buf に対して音声データーを埋める

alBufferData(buffer, AL_FORMAT_MONO16, buf, sizeof(buf), sampleRate);
alSourceQueueBuffers(source, 1, &buffer);
}



  1. 任意バッファに音声データーを設定

  2. alBufferData で OpenAL 側の Buffer に音声データーを転送

  3. alSourceQueueBuffers で OpenAL の Buffer を Source の再生キューに登録

とすることでバッファ関連の準備が行われます。ここでは alSourcePlay を呼び出す前に全部のバッファに対して初期状態のデーターを埋めておくようにします。

OpenAL で不思議だなあと思ったのは音声のフォーマットを指定するのがこの alBufferData のみで、デバイスのフォーマットを指定するタイミングが特にないところと、バッファ自体も OpenAL のバッファ自体は事前にフォーマットを指定するのではなく alBufferData である意味動的に変更ができそうになっているところです。


再生処理のループをまわす

ストリーミング再生なので再生の進捗に合わせて逐次データーを投入する必要があります。


C

void MainLoop()

{
if (isPlaying)
{
int processed;
alGetSourcei(source, AL_BUFFERS_PROCESSED, &processed);
while (processed > 0)
{
ALuint buffer;
alSourceUnqueueBuffers(source, 1, &buffer);
InternalProcess(buffer);
processed--;
}
}
}


  1. alGetSourcei を AL_BUFFERS_PROCESSED 指定で処理済のバッファ数を取得する

  2. alSourceUnqueueBuffers で処理済バッファを取得し、再度データー投入処理に回す

この処理を再生が行われている間継続して行うわけですが、ブラウザーでは (今のところ) スレッドを使えないのでメインスレッドで行います。メインスレッドは長時間占有するわけにはいきません。


C

#include <emscripten.h>


int main()
{
emscripten_set_main_loop(MainLoop, 60, 1);
return 0;
}

ということで Emscripten には main loop となる関数を登録する emscripten_set_main_loop という関数があるのでこれを使って MainLoop の呼び出しを一定の間隔で継続して呼び出しをさせるようにします。これにより他の処理を阻害せずに再生処理を行うことができるようになります。


JavaScript から Emscripten のコードを呼び出せるようにする

JavaScript から Emscripten で書いたコードを呼び出すために ccall という機能があります。

が、 Embind という機能が便利だったのでこっちを使います。 Embind は C++ のクラスをそのまま JavaScript のクラスとして使えるようにする機能ですが、 C の関数でも使えます。

Embind で宣言した関数、クラスは ccall と違い JavaScript 側からは JavaScript の関数、クラスとして扱えるようになるのでものすごくわかりやすく使えます。


C

void StartPlay()

{
// 再生開始処理を書く
}

とした場合、次のようにします。


C++

#include <emscripten.h>

#include <emscripten/bind.h>

EMSCRIPTEN_BINDINGS(WebAudioOpenAL)
{
emscripten::function("startPlay", &StartPlay);
}

EMSCRIPTEN_BINDINGS マクロでは任意の名前を指定します。

emscripten::function 関数で Binding の指定をします。第一引数の文字列は JavaScript 側に公開する関数名で、その次に対応する関数の関数ポインタを指定します。

公開する関数名は C/C++ 側の定義に合わせる必要はないので、 C/C++ 側と JavaScript 側で命名規則が違っている場合の吸収をすることもできるわけです。

また、Embind は std::string を引数型、戻り値型にすると JavaScript の文字列として扱えます。


C++

std::string CompileMML(const std::string& mml, int sampleRate)

{
// 略
}

EMSCRIPTEN_BINDINGS(mucom88)
{
emscripten::function("compileMML", &CompileMML);
}


これは JavaScript 側からは下記のように呼んでいます。


JavaScript

var rate = 44100;

var mml = document.getElementById('mml').value;
document.getElementById('result').innerText = Module.compileMML(mml, rate);

Embind を使用する場合、 emcc のオプションで "--bind" を指定する必要があります。


ビルドした Emscripten のコードを JavaScript にインポートする

では HTML にコードを書いて使ってみましょう。


HTML

<script type="module">

import Module from './WebAudioOpenAL.js';
</script>

と記述して実行してみると次のようなエラーになります。


Uncaught SyntaxError: The requested module './WebAudioOpenAL.js' does not provide an export named 'default'


この辺が理由なのですが、この issue を辿っていくと emcc に "-s MODULARIZE=1 -s EXPORT_ES6=1" をつけてコンパイルする、ということらしいのが分かりますがうまくいきません。

によると「EXPORT_ES6 option は issue に書いてあるようには動かない」とのことなので Google のサンプルと同様に後付けで "export default Module;" を記述するようにします。

emcc では "--post-js" で指定した JavaScript をビルドした JavaScript の最後に挿入することができます (先頭に挿入するのが "--pre-js" ) 。ただ、これも WASM=0 (WebAssembly なしの JavaScript のみでのビルド) かつ最適化を入れた状態で "--post-js" を指定すると emcc がエラーになるという罠がありました。最適化をした JavaScript のコードが emcc の Python スクリプトでうまく処理ができなかったようです。ということで MUCOM88 の Emscripten ビルドではビルドした .js に後付けで書き加えるようにしています。

CMake のカスタムコマンドで実行します。

add_custom_command(

TARGET ${CMAKE_PROJECT_NAME} POST_BUILD
COMMAND python ARGS "${PROJECT_SOURCE_DIR}/PostBuild.py" "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_PROJECT_NAME}.js")

今回のサンプルでは自前の書き換えではなく emcc の --post-js で付与しています。

Button の click イベントでコードを実行するようにします。


HTML

<Button id="btnPlay">Play</Button>

<script type="module">
import Module from './WebAudioOpenAL.js';

document.getElementById('btnPlay').addEventListener('click', function() {
Module.startPlay();
});
</script>


というところでボタンを押したら音がなるようになりました。


おわりに

C/C++ を使ってブラウザー上でストリーミング再生をする手順を解説しました。ブラウザー特有な実装に関してはこの記事で一通り解説したと思いますので、 C/C++ で実装されているソフトウェアシンセサイザーをブラウザーで動かすのは割と簡単にいけるのではないかと思います。

Emscripten はなかなかデバッグがつらいという感じはありますが、 JavaScript 出力かつ最適化なしだと JavaScript 状態でも雰囲気で追えるので割となんとかなりそうな感じです。ウェブ開発で C/C++ が使えるのは自分的にはメリット大きいのでこれからも使っていきたいところです。