本記事はJUCE Advent Calendar 2023 の12月25日向けに投稿した記事です。
本記事は、12月16日に投稿した記事 "JUCEとCHOCを組み合わせてオーディオプラグインのGUIをWeb技術で作ろう" で伝えきれなかった情報を補足する記事になります。
前回の振り返り
要約
JUCEとCHOCを組み合わせることで、オーディオプラグインのGUIをWeb技術で制作することができます。このアプローチは、複数の技術要素を組み合わせながらも、柔軟で効果的なGUIプロトタイピングを可能にします。
本記事では、筆者が作成した WebGain と DenoGain での実践を通じて、C++によるネイティブ向けのオーディオプラグイン、オーディオアプリケーションの開発にWeb技術を導入する手順について解説します。
なお、用いている技術スタックが多層であることと、開発フローがまだ標準化できていないため、本記事では、「WebViewを用いてGUIを構築する基礎技術」に焦点を当てており、Web技術スタックそのものについては触れません。また、ソースコードの解説は要点のみに留めさせていただくことをご承知おきください。
JUCEとは
JUCE(Jules' Utility Class Extensions)は、C++言語によるマルチメディア系アプリケーションの開発を支援するフレームワークです。クロスプラットフォーム設計のライブラリから Windows, macOS, Linux, iOS, Android で動作するアプリケーションを作成することができます。
Julian Storer氏がDAWソフトウェアを開発する過程でライブラリ化された経緯があることから、オーディオプラグインを開発するためのテンプレートが充実していることが挙げられます。VST3/AudioUnit/AAX/LV2/ARAプラグインといった、DTMユーザーにはお馴染みとなっているプラグインフォーマットをワンソースから開発することができます。
公式サイト
CHOCとは
CHOC(Classy Header Only Classes)は、上記のJUCEを開発した Julian Storer氏が新たに作成したC++のヘッダーオンリーなライブラリです。C++プロジェクトで標準ライブラリの不足を補完し、ビルド手続き不要で利用することができます。各ファイルは自己完結しており、必要な部分だけをプロジェクトに取り込むことができます。ISCライセンスで配布されています。
対象とするバージョン
- JUCE 7.0.9
- CHOC SHA:8ec4005bc8768312a4200f700180c8c696677400
- Visual Studio 2022
- Xcode 14.2
- Clang 14
- Ninja-build 1.11
- CMake 3.25以上
- Git 2.3以上
サンプルコードについて
本記事で取り上げるサンプルコードはGitHubリポジトリで確認することができます。
このリポジトリでは、WebGain と DenoGain という2つのオーディオプラグイン(VST3, AudioUnit, CLAP)をビルドすることができます。
WebGain
WebGainでは、単一のHTMLファイルをC++のバイナリデータとして組み込み、プロジェクトにシームレスに統合します。コントローラーはHTMLのinput要素を使用し、背景の描画にはp5.jsが、HTML要素のアニメーションにはCSSアニメーションが利用され、GUIが構築されます。
DenoGain
DenoGainでは、Vite + Deno + Svelte + TypeScriptといったWebフロントエンド開発で用いられる技術スタックを使用します。ビルドシステムによって生成された配布用のWebリソースを描画することと、開発用のサーバーからWebリソースを取得することもできることを確認しています。
このプログラムでは、Webリソースの提供はCHOCのAPIを用いた簡易なWebサーバー相当の関数を実装し、WebViewからのFetch呼び出しに応じてリソースをストリームする手法でWebViewに提供します。
WebViewの開発に関する補足情報
開発サーバーからWebリソースを取得する
WebView(HTML, CSS, JavaScript)を開発途中における動作確認は、Webフロントエンド開発環境で設置したWebサーバーを用いて行うことができます。具体的な事例として、本記事のサンプルコードの DenoGain では以下のコマンドから開発サーバーを設置することができます。
※Node.jsで開発している場合は、コマンドをnpmに置き換えることで同様の開発環境での確認を行うことができます。
cd Plugins/DenoGain/WebView/vite-project
$ deno task dev
VITE v5.0.8 ready in 13815 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h + enter to show help
開発サーバーを設置した状態で、C++側のソースコードで、WebViewのnavigate先を開発サーバーに向けます。
AudioPluginAudioProcessorEditor::AudioPluginAudioProcessorEditor (AudioPluginAudioProcessor& p)
: AudioProcessorEditor (&p)
, processorRef (p)
, valueTreeState (p.getAPVTS())
{
~省略~
chocWebView = std::make_unique<choc::ui::WebView>(options);
#if JUCE_WINDOWS
juceHwndView = std::make_unique<juce::HWNDComponent>();
juceHwndView->setHWND(chocWebView->getViewHandle());
addAndMakeVisible(juceHwndView.get());
#elif JUCE_MAC
juceNsView = std::make_unique<juce::NSViewComponent>();
juceNsView->setView(chocWebView->getViewHandle());
addAndMakeVisible(juceNsView.get());
#elif JUCE_LINUX
juceXEmbedView = std::make_unique<juce::XEmbedComponent>(chocWebView->getViewHandle());
addAndMakeVisible(juceXEmbedView.get());
#endif
// Make sure that before the constructor has finished, you've set the
// editor's size to whatever you need it to be.
setSize (400, 800);
setResizable(true, true);
~省略~
chocWebView->bind("onToggleChanged", web_view_callback_on_toggle_changed);
chocWebView->bind("onSliderChanged", web_view_callback_on_sliider_changed);
chocWebView->bind("onInitialUpdate", web_view_callback_on_initial_update);
// 設置した開発サーバーからWebリソースを取得する
chocWebView->navigate("http://localhost:5173/");
valueTreeState.addParameterListener("gain", this);
valueTreeState.addParameterListener("invertPhase", this);
}
JUCEにWebViewを組み合わせる際のイシュー
前回の記事では、JUCEのGUIをWebViewで実装することについてのポジティブな側面について触れてきましたが、WebViewがJUCE開発における銀の弾丸のようなものかというと、そうとは言い切れない側面もあります。ここでは、イシュー項目について触れています。
JUCEのUIコンポーネントとWebViewを混ぜることが難しい
本記事のサンプルコードでは、CHOC APIから生成したWebViewをJUCEのGUIウインドウ内に取り込む手段として、juce::HWNDComponent
, juce::NSViewComponent
を用いています。
元記事:1. CHOCのWebViewをJUCEコンポーネントに統合する
この手法のネガティブなポイントとして、JUCEのUIコンポーネントと混ぜることが難しいということが挙げられます。juce::HWNDComponent
, juce::NSViewComponent
のAPIリファレンスには以下のように記述されています。
Of course, since the window is a native object, it'll obliterate any JUCE components that may overlap this component, but that's life.
意訳
もちろん、ウィンドウはネイティブオブジェクトなので、このコンポーネントと重なる可能性のあるJUCEコンポーネントはすべて消去されますが、仕方ないよね。
この課題については、@Talokayさんの記事が参考になります。
ここで起こる問題が、Heavy-Weight(ヘビー君)とLight-Weight(ライトちゃん) Componentの問題です。
VideoComponentに限らず、OpenGL、Web系のComponentは全てヘビー君であり、ライトちゃんであるJuceのComponentと同じレベルで使用すると、ヘビー君が常に、あらゆるライトちゃんよりも前面に表示される、という問題が起こります。
CHOC APIのWebViewだけでなく、JUCE APIの juce::WebBrowserComponent
を使用する場合でも、同様の問題に遭遇することがあります。この問題に対処するためには、WebViewのレンダリング結果をキャプチャしてJUCEのUIコンポーネント上で再レンダリングするなどの手法が考えられます。ただし、これらの手法は新しい実装が必要であり、簡単なアプローチではありません。
この種の課題に対処するための解決策の実例が既に存在し、それを参考にすることができます。具体的な例として、Ultralight と呼ばれるサードパーティのヘッドレスHTMLレンダラをC++プログラムに組み込む方法があります。なお、この解決方法はCHOC APIを用いたOSネイティブのWebViewを組み込む方法とは異なるため、本記事で解説しているアプローチとは大きく異なる点にご注意ください。
上記のリポジトリでは、Ultralight を使用してWebリソースのレンダリングを行い、その結果をBitmapとして取得し、最終的にJUCEのGUIレンダリングシステム内で再レンダリングするプロセスが実装されています。これにより、JUCEのUIコンポーネントとWeb技術スタックで実装されたUI要素とをミックスすることができます。
WebView::evaluateJavascript は同期的にreturnを返さない
タイトルの通り、WebView::evaluateJavascript
関数は同期的にreturn値を返さない仕様になっています。これは、WebView上で実行するJavaScriptコードは、C++から見て非同期処理で行われることを意味します。WebView上で実行するJavaScriptコードで何かを処理させたい場合は、その点を意識して実装する必要があります。
標記の件については、JUCEフォーラムでも議論が行われていました。
This is a little more complicated. In your JavaScript, instead of returning a value, you need to call back to a C++ function that you’ve bound using WebView’s bind. Pass the value you want to return to C++ as a parameter to that function. Then in C++, your C++ lambda gets called, and the choc::value::ValueView args is the “returned” value from JavaScript.
Note that this will be asynchronous Meaning: When your call to evaluateJavascript() returns, your C++ lambda (from bind()) hasn’t yet been called, so the “return” value isn’t there yet. Adjust your code to be async and continue inside your lambda, once that gets called.
なお、同期的に処理したいJavaScriptコードを実行する目的であれば、juce::JavascriptEngine
, choc::javascript
を使用するという解決方法があります。
namespace juce
{
...
class JUCE_API JavascriptEngine final
{
public:
/** Creates an instance of the engine.
This creates a root namespace and defines some basic Object, String, Array
and Math library methods.
*/
JavascriptEngine();
/** Destructor. */
~JavascriptEngine();
/** Attempts to parse and run a block of javascript code.
If there's a parse or execution error, the error description is returned in
the result.
You can specify a maximum time for which the program is allowed to run, and
it'll return with an error message if this time is exceeded.
*/
Result execute (const String& javascriptCode);
/** Attempts to parse and run a javascript expression, and returns the result.
If there's a syntax error, or the expression can't be evaluated, the return value
will be var::undefined(). The errorMessage parameter gives you a way to find out
any parsing errors.
You can specify a maximum time for which the program is allowed to run, and
it'll return with an error message if this time is exceeded.
*/
var evaluate (const String& javascriptCode,
Result* errorMessage = nullptr);
...
namespace choc::javascript
{
...
class Context
{
public:
/// To create a Context, use a function such as choc::javascript::createQuickJSContext();
Context() = default;
Context (Context&&);
Context& operator= (Context&&);
~Context();
/// When parsing modules, this function is expected to take a path to a module, and
/// to return the content of that module, or an empty optional if not found.
using ReadModuleContentFn = std::function<std::optional<std::string>(std::string_view)>;
//==============================================================================
/// Evaluates the given chunk of javascript.
/// If there are any parse errors, this will throw a choc::javascript::Error exception.
/// If the engine supports modules, then providing a value for the resolveModuleContent
/// function will treat the code as a module and will call your function to read the
/// content of any dependencies.
/// None of the methods in this class are either thread-safe or realtime-safe, so you'll
/// need to organise your own locking if you're calling into a single Context from
/// multiple threads.
choc::value::Value evaluate (const std::string& javascriptCode,
ReadModuleContentFn* resolveModuleContent = nullptr);
...
上記のJavaScriptエンジンを用いてJavaScriptコードを実行する場合と、WebView上でJavaScriptコードを実行する場合との違いは、WebViewで実行できるAPIがJavaScriptエンジンの方には実装されておらず、実行できないAPI呼び出しが存在します。