📻

JUCEとWebAudioAPIでロスレス音源をReal Time Streaming

2023/12/20に公開

https://qiita.com/advent-calendar/2023/dena-24-newgrad

はじめに

こんにちは!お久しぶりのsiy1121です。
今回は自分が最近DTMで抱えている悩みを解決するために、 「任意のデバイスをサブスピーカー化するアプリ」 を開発したので、開発の過程や実装のポイントをお話ししていきます!


今回開発したシステムの概要

趣味DTMerが抱える悩み...

楽曲制作にはミキシング・マスタリングという工程があります。この工程では楽曲の音のバランスを注意深く調整することになるため、良いリスニング環境に身を置く必要があります。

しかし、趣味でDTMをやっている人にとって良いリスニング環境を整えることは難しいです。そもそも環境的に大きなスピーカーで音を出すのが難しいことも多いです。
よって「ヘッドフォンだけ」と言ったような偏った環境でミックスすることになりますが、そうすると別の環境で聞いたときにバランスが崩れている!という状況が発生します。
そこで、できるだけ複数の環境でバランスを調整する必要があります。

スタジオシミュレーターや調音用のプラグインを使用することもできますが、
身の回りにはスマホ、タブレット、別のPCなど、スピーカーを持ったデバイスが多く存在します。
そこで、これらのデバイスをバランス確認用のサブスピーカーとして活用しよう!というのが今回のモチベーションになります。

やりたいこと

DAW上で再生されている音を任意のデバイスにリアルタイムでストリーミングします。
これにより、複数のスピーカーで楽曲のバランスを確認できます。
また自分専用システムではなく、一般ユーザーへの配布も視野に入れています。

既にAudioMoversMixToMobileといった製品が存在しますが、お金がかかるので自分で作ってみよう!というモチベーションです。(DTM界隈でもサブスクが増えてきたので困ります😇)

https://audiomovers.com/
https://soundondigital.com/products/mix-to-mobile/

具体的な要件

アプリを開発するにあたっていくつか要件をまとめました。

ストリーミングの過程で音源が劣化しないこと

普段我々が耳にする音源の多くは非可逆圧縮音源(opus/aac/mp3等)で、音の劣化を許容することで高い圧縮率を実現しています。
鑑賞目的であれば問題になりませんが、今回のユースケースではなるべく劣化しない方式で転送する方がベターです。

送受信のデバイスは同じLANにあるとして良い

今回のユースケースは、あくまで制作中の楽曲のバランスを別のスピーカーで確認することなので、同じネットワーク内にデバイスがある状況で動けば問題ありません。

維持コストゼロ

このアプリを使用し続けるのに、サーバーの維持費やアプリストアへの支払いなど、継続してお金がかかる状態は避けたいです。
これにより以下の条件が発生します。

  • 受信側はWebアプリとして完結すること
    • ネイティブアプリはストアへの支払いなど維持費がかかる
  • 「DAWを起動しているPC」と「サブスピーカーとして使いたいデバイス」の2デバイスのみで完結すること
    • 例えばパブリッククラウド等を使用すると維持費がかかるので❌
    • 一般ユーザーへの配布も視野に入れているため、複雑なセットアップが必要な構成も❌

上記二つの条件から以下の条件も発生します。

  • ユーザーのローカルPCでhttpサーバーを立ててwebアプリをホストすること
    • TLS化が難しいので安全なコンテキストでなくても動作するwebアプリである必要がある

Webアプリとして動作すれば、ブラウザが動く任意のデバイスをサポートできる利点もあります。

技術選定

これらの要件から採用する技術を決めます。
実装にあたっては以下の方法を決めなければなりません。

  1. DAWで流れている音をキャプチャする方法
  2. 使用するオーディオコーデックとネットワークプロトコル
  3. ブラウザ側で音を再生する方法

DAWで流れている音をキャプチャする

OSやオーディオデバイスに依存せず安定してDAWの音をキャプチャするには、
やはりDAWのプラグインとして実装するのが一番良いでしょう。
DAWのプラグイン形式にはVST/AU/AAX/CLAPと様々な種類がありますが、一つのコードベースで複数OS/プラグイン形式に対応できるJUCEフレームワークを採用します。

https://juce.com/

オーディオプラグインというリアルタイム処理が求められる性質上、GCが搭載されていない言語で開発することが一般的です。
現在のデファクトスタンダードはC++であり、JUCEもC++を使用して開発します。

オーディオコーデックとネットワークプロトコル

音源を劣化させることなく転送するには

  • 可逆圧縮のコーデックを使う
  • そもそも圧縮しない

の二通りが考えられますが、せっかくなので何かしらのコーデックを使用したいところです。

今回はオーディオの可逆圧縮コーデックとして有名なflacを使ってリアルタイムストリーミングしてみようと思います。

またブラウザで使用でき、リアルタイム性が担保できそうなネットワークプロトコル・APIの候補として以下が上げられます。

WebSocket

まず WebSocket ですが、どこでも動く無難なプロトコルだと思います。ただし、TCP上のプロトコルのため通信状況が悪いと後続のパケットが詰まる Head of Line Blocking が問題になることがあります。今回の場合は通信が途切れるたびに再生遅延が大きくなるという問題につながります。

Server-sent Events

Server-sent EventsWebSocket と同じく Head of Line Blocking 問題を抱えつつ、更にバイナリデータの送信にbase64化が必要でありオーバーヘッドが大きいです。

WebTransport

WebTransportUDP/QUIC/http3 で実現されているため、Head of Line Blocking 問題を避けることができます。しかし、http3準拠のためTLS化が必須であり、TLS化が難しいローカルサーバーで利用することは困難です(一般ユーザーへの配布を考えると特に)。

WebRTC DataChannel

WebRTC は音声と映像のストリーミングが可能ですが、flacといった非対応のコーデックはそのまま転送することはできないため、任意のデータを流せる DataChannel を使用することになります。またHead of Line Blocking問題も避けることもできます。

これらの選択肢を眺めると WebRTC DataChannel が一番適していると考えられます。

しかし、今回は自分の技量と時間の問題からWebSocketを採用することにしました。
今回のシステムでWebRTCを採用するには以下の調査が追加で必要です。

  • 非TLSな安全でないコンテキストで動作するか
  • JUCEとlibwebrtcを共存させる方法

C++でWebSocket通信をする

Oat++ を使います。
Oat++はC++でハイパフォーマンスなhttpサーバーを実現するためのフレームワークで、外部モジュールとしてWebSocketが利用できます。
また、今回はクライアント側のWebアプリ向けに静的ファイルをホストする必要があるため、Oat++を使うことでこれらのファイルも同時に提供できます。

https://oatpp.io/

ブラウザ側で音を再生する

ブラウザ側で音を流すには、

  • flacのデコード
  • 短いフレームに分割されている音をシームレスに再生する

ことが必要になります。

まずflacのデコードですが、以下の3つの方法が考えられます。

WebAudioAPIのdecodeAudioData

圧縮されたオーディオデータをデコードする一般的な方法です。
しかしファイル単位でのデコードになるため、今回のように細切れにフレーム分割された音源をデコードすることはできません。(正確には、無理やり使用することはできますが効率的ではありません)
https://developer.mozilla.org/ja/docs/Web/API/BaseAudioContext/decodeAudioData

WebCodecs

最近追加された映像や音声をデコードできる低レベルなAPIです。
しかし、safariにおいて音声コーデックが軒並みサポートされていないので今回は利用できません。

https://developer.mozilla.org/en-US/docs/Web/API/WebCodecs_API

libflacjs (外部ライブラリ)

ブラウザのAPIに頼ることなくflacをデコードできる外部ライブラリです。
本家のflac実装であるlibflac(C/C++)のJavaScript向けのラッパーとして実装されており、wasmも使用できるため、効率的な動作が望めます。

https://github.com/mmig/libflac.js/tree/master

今回は消去法的に libflacjs を使用することになります。

libflacjsでデコードされた音は細かいフレームに分割されており、適切なタイミングで再生する必要があります。
音楽ファイルをAudioタグで再生するシンプルな状況とは異なり、適切な制御が必要なため今回はWebAudioAPIを使用します。

実装方針

今までの議論を踏まえて以下のような構成になりました。

ユーザーは、以下の手順でアプリを利用できます。

  1. DAWのマスタートラックにプラグインをインサート
  2. プラグインがホストしているhttpサーバーにブラウザでアクセスする

実装ポイント(プラグイン・送信側)

プラグイン・送信側の実装について重要な点を解説します。

CMake

C++のプロジェクトで良く用いられているビルドシステムです。
CMakeそれ自体はコンパイラではなく、環境に合わせたコンパイラ向けのプロジェクトファイルを生成・実行してくれます。
今回のシステムはWindowsとmacOS向けにビルドすることができるため、WindowsではVisualStudioのソリューションファイル、macOSではXCodeのプロジェクトファイルを生成してくれます。これにより異なるOSでも簡単にビルドができます。

CMakeのプロジェクトは CMakeLists.txt によって定義されています。
依存ライブラリやコンパイルオプション等が記述されています。

https://github.com/SIY1121/juce-flac-streaming/blob/main/plugin/CMakeLists.txt

JUCE AudioProcessorのインターフェース

オーディオプラグインはまずオーディオとMIDIメッセージをDAWから受け取ります。
そのデータをプラグイン内で処理し、オーディオとMIDIメッセージを返します。
(プラグインの種類によってはオーディオだけ、MIDIメッセージだけということもあります)

クラス juce::AudioProcessor は このオーディオプラグインのインターフェースで、これを継承することでプラグインを開発します。
ブロック図に示されている「何らかの処理」は processBlock 関数内に記述します。

今回はオーディオをキャプチャすることが目的なので、入力されるオーディオ信号をコピーするだけで、オーディオとMIDIメッセージは素通しします。
https://github.com/SIY1121/juce-flac-streaming/blob/main/plugin/src/PluginProcessor.cpp#L126-L132

メモリとCPUに優しいRingBuffer

コピーしたオーディオ信号はFlacEncoderでエンコードし、WebSocketを通して送信することになります。しかしその処理を processBlock にべた書きしてしまうとそれらすべての処理を同じスレッド内で実行することになり、DAW側の処理を遅延させます。そして最悪音がブツブツと途切れるようになります。

この問題を解決するために、一度Queueにオーディオ信号を入れて、別のスレッドからそのオーディオ信号を取り出し、後続の処理を行うという方法を取ります。

ここでQueueの実装に注意する必要があります。
具体的には以下の要件を満たすべきです。

  • 高頻度なメモリのアロケーションを防ぐため固定長の配列を用いてQueueを実装すること
  • Queueされた要素を別のスレッドが検知してDequeueできること

オーディオの設定が仮に44.1kHz/2ch だとすると このQueueには毎秒88200個の要素が出し入れされることになります。仮に要素が挿入されるたびにメモリアロケーションが発生するとパフォーマンスに影響します。そこで、「事前に挿入できる要素数の上限を決めてしまうことで、メモリアロケーションを発生させない」 という方法を取ります。このように固定長配列で実装されたQueueをRingBuffer、循環バッファと呼びます。

RingBufferは、固定長配列の始端と終端をつなぎリングのように図示することができます。
以下の図は、1.をQueueが空の状態とし、そこから要素を3つ挿入、その後要素を2つ取り出すときの挙動を表しています。


RingBufferで要素を出し入れする例

続いて、Queueされた要素を別のスレッドが検知してDequeueするためにはどうすればよいでしょうか?
ベタな方法として、取り出す側のスレッド(thread2)で 無限ループ内でリングバッファの状態を確認する方法があります。


無限ループで常に内容をチェック

しかし、この方法では常にループが動いており、無駄なCPU時間を消費してしまいます。
そこで今回は条件変数 を用います。

条件変数を用いることで、要素が存在しない場合はthread2をスリープ状態にし、thread1が要素を挿入したタイミングでthread2を起こすことができます。これにより無駄なコンテキストスイッチや処理が発生しなくなります。

https://github.com/SIY1121/juce-flac-streaming/blob/main/plugin/src/utils/AudioRingBuffer.h

Flacの基本的なフォーマットとlibflacの使用方法

RingBufferを経由して到着したオーディオ信号はFlacEncoderに入力されます。
ここで、native Flacのフォーマットについて見てみましょう。

https://xiph.org/flac/format.html

以下の図はnative Flacのフォーマットを表しています。


Flacフォーマット概要

上から順に

  1. これがFlacストリームであることを示す fLaC というマーカー
  2. Flacのデコーダの準備に必須な情報が含まれている STREAMINFOブロック
  3. 楽曲名やトラック番号などの必須ではないメタデータが含まれる METADATA_BLOCK (任意の数)
  4. 圧縮されたオーディオ信号が含まれる FRAME (1つ以上)

で構成されます。

Flacのエンコード・デコード単位は FRAME であり、1フレーム当たり16~65535サンプルのオーディオ信号が含まれます。
つまりエンコード・デコードはオーディオ信号全体を一気に圧縮するのではなく、信号を細切れに分割し、その分割した信号ごとに圧縮をしています。

FlacEncoderは初期化のタイミングで fLaCSTREAMINFO を出力します。
その後RingBufferを経由して到着したオーディオ信号を入力していくと、対応する FRAME がどんどん出力されます。
この出力された FRAME をWebSocketでクライアントに向けて送信します。

一方FlacDecoderは fLaCSTREAMINFO を初めに入力することで初めて FRAME をデコードする準備が整います。よってブラウザ側(受信側)はWebSocketを接続したタイミングでまずこれらを受け取る必要があります。
よって送信側は、FlacEncoderの初期化時に出力される fLaCSTREAMINFO を保持しておき、新しいWebSocket接続が来たらまずこれらを送信する必要があります。その後、現時点で最新の FRAME から送信することで途中からでもリアルタイムストリーミングに参加できます。


最初の fLaCSTREAMINFO さえあれば、 FRAME が欠落していても任意のFRAMEからデコードできる

C++向けHttp/WebSocket ライブラリ Oat++

C++でWebサーバーを実装するのは、他の言語と比べて面倒に思うかもしれません。
しかしOat++のマクロを活用することで、ある程度モダンな書き心地や機能を実現できます。

エンドポイントを宣言する例

  ENDPOINT("GET", "/hello", root) {
    auto dto = MessageDto::createShared();
    dto->statusCode = 200;
    dto->message = "Hello World!";
    return createDtoResponse(Status::CODE_200, dto);
  }

DIコンテナを用いて依存注入をする例

// 宣言
class AppComponent {
public:

  OATPP_CREATE_COMPONENT(std::shared_ptr<oatpp::network::ServerConnectionProvider>, serverConnectionProvider)([] {
    return oatpp::network::tcp::server::ConnectionProvider::createShared({"localhost", 8000, oatpp::network::Address::IP_4});
  }());
}

// ...

// 任意の場所でコンポーネントを注入できる
OATPP_COMPONENT(std::shared_ptr<oatpp::network::ServerConnectionProvider>, connectionProvider);

https://oatpp.io/docs/start/step-by-step

Multicast DNSの実装

Oat++で建てたhttpサーバーにアクセスするには、現状PCのipアドレスをブラウザに入力する必要があり好ましくありません。そこで、Multicast DNS(MDNS)を実装して juce-flac-streaming.local のようなアドレスでアクセスできるようにします。

MDNSは一般的なDNSクエリを 224.0.0.251 にマルチキャストし、そのクエリに任意のコンピュータが返答することで名前を解決するシステムになっています。
多くのブラウザでサポートされており、 .local で終わるアドレスを入力すると自動でMDNSによる名前解決を試みます。
つまり、ブラウザのMDNSクエリに応答することでブラウザにipを通知することができます。
今回はcで実装されたmdnsライブラリを用いて、ブラウザからのMDNSクエリに応答します。

https://github.com/mjansson/mdns

https://github.com/SIY1121/juce-flac-streaming/blob/main/plugin/src/mdns/mdns_service.cpp#L1-L45

実装ポイント(ブラウザ・受信側)

続いて、ブラウザ・受信側の実装についてポイントを解説します。

Vite / React

画面一枚のシンプルなWebアプリになるため、Next.js等の重めのフレームワークは使用せず、Vite環境でReactという身軽な構成にしました。
(作ってみるとかなりシンプルになったのでReactすら不要だったかもしれません)

libflacjs

本家libflacで提供されているcのapiをそのまま利用できます。
WebSocketから受信したフレームを、FlacDecoderに入力することで、デコードされたオーディオ信号を取得できます。

以下はWebSocketから受信したFRAMEをFlacDecoderに入力するコードです。

https://github.com/SIY1121/juce-flac-streaming/blob/main/client/src/player/player.ts#L71-L97

WebAudioAPIを用いた分割された音声の適切なスケジューリング

FlacDecoderから出力される、細切れのオーディオ信号をつなぎ目なく再生するにはどうすればよいのでしょうか?

例えばプラグイン側からデータが届き次第すぐに再生する場合はどうでしょうか?
この場合、ネットワークの問題等でデータ到着に遅延が発生すると、再生に間に合わずに無音が発生する可能性があります。この無音はブチブチという不快な音を生みます。

そこで受け取ってもすぐに再生せず、ある程度余裕をもって再生をするようにスケジューリングします。そうすることで、多少遅延が発生しても無音区間が発生せずに済みます。

JavaScriptにおける遅延処理と言えば setTimeout ですが、JavaScriptにおけるイベントループとタスクキューの動作から、正確なスケジューリングは困難です。
特に今回は 0.01ms レベルの精度が求められます。この場合、 setTimeout を使用するのではなく、WebAudioAPIの機能を利用します。

const audioContext = new AudioContext()
const source = audioContext.createBufferSource()
source.buffer = buffer // 任意の音を含むbuffer
source.connect(audioContext.destination)
source.start(scheduleTime) // scheduleTime分遅らせて再生する

このコードはWebAudioAPIで音を出す最小のコード例ですが、BufferSource.start メソッドの引数に遅延させたい秒数を指定することでブラウザ側が正確にスケジューリングを行ってくれます。
例えば 0.01ms遅らせて再生したい場合は 0.001 * 0.01を指定します。

以下はFlacDecoderから受け取ったオーディオ信号をスケジューリングして再生するコードです。

https://github.com/SIY1121/juce-flac-streaming/blob/main/client/src/player/player.ts#L99-L140

おわりに

今まで自分が触ってきた技術(Webフロントエンド・バックエンド・マルチメディアを扱うプログラミング等)を組み合わせて、自分の創作ライフを充実させることができて満足です!
モダンなブラウザが動作するデバイスなら動作するため、今後新しい使い方も思い付くかもしれません。

コードは以下で公開しているので良ければビルドして遊んでみてください。
(もう少しブラッシュアップすればちゃんと配布できそう...)

https://github.com/SIY1121/juce-flac-streaming/tree/main

最後まで読んでいただきありがとうございました!

明日は@Usuyuki さんの記事です!

Discussion