本記事は JUCE Advent Calendar 2025 の12月25日向けに投稿した記事です。
はじめに
先日、ADC (Audio Developer Conference) 2025で公開された講演「PSD to DAW: Effective Workflows for Designing & Implementing Audio Plugin UIs」を視聴しました。
この講演、個人的には非常に面白い内容でした。メタル系楽曲向けのアンプシミュレーターやドラム音源を開発・販売するBogren Digital社のエンジニアが、Photoshopで作ったデザインをJUCEのGUIに落とし込むまでのワークフローを、かなり踏み込んで解説していました。フォトグラフィックなプラグインUIを作るときの具体的なテクニックや、デザイナーとプログラマーの橋渡しをどう効率化するか、といった実践的な話が満載でした。
この講演の中で紹介されたライブラリがOSSで公開されていたので、実際に触ってみることにしました。
本記事では、bd_ui_loaderというライブラリを使って、XML形式でUIを定義し、フィルムストリップ形式のノブ画像を使ったプラグインUIを作ってみた過程を紹介します。
bd_ui_loaderについて
bd_ui_loaderは、Bogren Digital社がGitHubで公開しているOSSライブラリです。JUCEで開発したアプリケーションのUIをXMLファイルで定義できるようにするツールで、上記のADCでの講演によると、Bogren Digital社のプラグイン製品の開発でも使われているとのことです。
このライブラリの特徴は、フォトグラフィックなUI開発で実績のある、PhotoshopやFigma等のデザイン作成ツールから出力したPNG画像素材を配置するワークフローを前提としていることです。フィルムストリップ方式の画像素材を使用しつつ、レイアウトはXMLで記述することで、C++でハードコーディングせずともオーディオプラグインのGUIのグラフィカル要素を組み込むことができます。
このライブラリが提供する主な機能の概要:
- XMLでコンポーネントの配置とプロパティを記述できる
- フィルムストリップ方式の連番画像に対応する
- JUCEモジュールとして実装されており、ProjucerやCMakeからプロジェクトに追加できる
- JUCEのバイナリリソース(BinaryData)の仕組みと相性が良い
実際に作ったもの
本記事の解説に際して、筆者が作成したプロジェクトは以下のGitHubリポジトリで公開しています。
実際に表示されるUIの画面:
解説:プロジェクト構成について
今回作成したプロジェクトの構成は以下の通りです:
tried-bd-moduels-2025/
├── External/
│ ├── Gin/
│ ├── JUCE/
│ ├── bd_binary_asset_utilities/
│ ├── bd_image_resampler/
│ ├── bd_ui_loader/
│ └── playfultones_smoothresizing/
├── Resources/
│ ├── my_plugin_ui.xml
│ ├── Background.png
│ ├── Knob_0.png
│ ├── Knob_1.png
│ ├── ...
│ └── Knob_127.png
├── Source/
│ ├── PluginEditor.cpp
│ ├── PluginEditor.h
│ ├── PluginProcessor.cpp
│ └── PluginProcessor.h
├── Tools/
│ ├── knob_filmstrip.png
│ └── split_filmstrip.ts
├── .gitignore
├── .gitmodules
├── CMakeLists.txt
├── CMakePresets.json
├── LICENSE
└── README.md
詳細:CMake設定について
今回作成したプロジェクトでは以下のCMake設定を記述してプロジェクトを構成しました。
まずは全体を示し、要所を個別に解説します。
cmake_minimum_required(VERSION 3.25)
project(tried-bd-modules-2025 VERSION 1.0.0)
enable_language(CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
if(CMAKE_CXX_COMPILER_ID MATCHES "MSVC")
add_compile_options(/utf-8)
endif()
add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/External/JUCE)
set(BaseTargetName PluginWithBDModules)
# Add JUCE modules for bd_ui_loader and its dependencies
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/Gin/modules/gin)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/Gin/modules/gin_graphics)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/bd_binary_asset_utilities)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/bd_image_resampler)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/playfultones_smoothresizing)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/bd_ui_loader)
# Collect all knob frame images
file(GLOB KNOB_FRAMES "${CMAKE_CURRENT_LIST_DIR}/Resources/Knob_*.png")
# Add binary data target with all resources
juce_add_binary_data(PluginBinaryData
SOURCES
Resources/my_plugin_ui.xml
Resources/Background.png
${KNOB_FRAMES}
)
juce_add_plugin("${BaseTargetName}"
COMPANY_NAME "MyCompany"
IS_SYNTH FALSE
NEEDS_MIDI_INPUT TRUE
NEEDS_MIDI_OUTPUT FALSE
IS_MIDI_EFFECT FALSE
EDITOR_WANTS_KEYBOARD_FOCUS FALSE
COPY_PLUGIN_AFTER_BUILD FALSE
PLUGIN_MANUFACTURER_CODE Cox2
PLUGIN_CODE Pbdm
FORMATS AU VST3 Standalone
PRODUCT_NAME "PluginWithBDModules")
target_sources(${BaseTargetName} PRIVATE
Source/PluginProcessor.cpp
Source/PluginProcessor.h
Source/PluginEditor.cpp
Source/PluginEditor.h)
target_compile_definitions(${BaseTargetName}
PUBLIC
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
JUCE_VST3_CAN_REPLACE_VST2=0)
target_link_libraries(${BaseTargetName}
PRIVATE
PluginBinaryData # Link the binary data target
juce::juce_audio_utils
bd_binary_asset_utilities
bd_image_resampler
bd_ui_loader
PUBLIC
juce::juce_recommended_config_flags
juce::juce_recommended_lto_flags
juce::juce_recommended_warning_flags)
詳細:JUCEモジュールの追加セクション
bd_ui_loaderはJUCEモジュールとして実装されているため、まずCMake設定からjuce_add_moduleでプロジェクトに追加する必要があります。今回は複数のBogren Digital社製モジュールと、bd_ui_loaderが依存するginとplayfultones_smoothresizingを使用するため、以下のようにCMake設定に記述してプロジェクトに追加します。
# Add JUCE modules
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/Gin/modules/gin)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/Gin/modules/gin_graphics)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/bd_binary_asset_utilities)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/bd_image_resampler)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/playfultones_smoothresizing)
juce_add_module(${CMAKE_CURRENT_LIST_DIR}/External/bd_ui_loader)
これらのモジュールはbd_ui_loaderの依存関係として必要になります。
詳細:バイナリデータの設定セクション
次に、juce_add_binary_dataでリソースをバイナリデータとして取り込みます。今回使用した128フレームのノブ画像を1個ずつ列挙するにあたり、CMakeの機能を利用してfile(GLOB)で一括収集します。
# Collect all knob frame images
file(GLOB KNOB_FRAMES "${CMAKE_CURRENT_LIST_DIR}/Resources/Knob_*.png")
# Add binary data target with all resources
juce_add_binary_data(PluginBinaryData
SOURCES
Resources/my_plugin_ui.xml
Resources/Background.png
${KNOB_FRAMES}
)
Tips:連番画像の抽出は file(GLOB) が便利
今回作成したプロジェクトでは、ノブの画像は128枚の連番画像で作成しました。128枚の画像のファイル名を手動で列挙するのは時間がかかるため、file(GLOB)でKnob_*.pngにマッチするファイルを自動収集します:
file(GLOB KNOB_FRAMES "${CMAKE_CURRENT_SOURCE_DIR}/Resources/Knob_*.png")
これでフレーム数を変更しても、CMakeLists.txtを編集する必要がなくなります。
リソース:背景画像を準備する
プラグインUIの背景画像として、水平方向のヘアラインノイズを適用した暗めの金属プレートの画像を準備します。この背景画像は600x600ピクセルのPNG形式で、ダークメタリックな質感を持つUI背景として使用されます。
解説:背景画像の仕様
-
サイズ: 600x600ピクセル(XMLで指定するUIウィンドウサイズと一致、リサイズ処理は
bd_ui_loaderにて行う) - 形式: PNG(透過なし)
- ベースカラー: ダークグレー系のメタリックカラー(RGB: 80, 85, 90付近)
-
効果:
- 水平方向のヘアラインノイズによる金属プレートの質感
- エッジに配置されたなめらかなビネットシャドウ効果
- 上下に強調されたシャドウで立体感を表現
準備した背景画像は、プロジェクトのResources/ディレクトリにBackground.pngとして保存します。
リソース:ノブ画像を準備する
今回はノブ画像の素材を作成する手法として、WebKnobManでフィルムストリップ方式の1枚画像を作成し、1枚の画像ファイルからコマ単位の複数の画像素材に分解するスクリプトをDeno/TypeScriptで作成しました。
解説:ノブ画像の仕様
-
サイズ: 各フレーム128x128ピクセル(リサイズ処理は
bd_ui_loaderにて行う) - 形式: PNG(アルファチャンネル付き、透過背景推奨)
- フレーム数: 128フレーム
- 回転角度: -135°(最小値)から+135°(最大値)までの270°回転
-
ファイル命名規則:
-
Knob_0.png(最小値、-135°)からKnob_127.png(最大値、+135°)まで連番 - プレフィックス(
Knob_)とサフィックス(.png)はXMLで指定
-
-
画像内容:
- 各フレームは独立した静止画
- 透過背景を使用することで、任意の背景上に配置可能
- アンチエイリアシング処理を施し、滑らかな回転表現を実現
フレーム数について
128フレームという数値は、MIDIコントロールチェンジの値が0-127の128段階で表現されることに由来しています。なお、オーディオパラメータは0.0~1.0の範囲の浮動小数点で表現されることから、128フレーム以上でフレーム数が多いほど滑らかなアニメーションを表現することができます。しかし、フレーム数が多いほどバイナリリソースのデータサイズが増えるためプログラムの最適化との間でトレードオフとなります。
フレーム数を変更する場合は、XMLのnumberOfFrames属性と、実際に生成する画像ファイルの数を一致させる必要があります。
解説:WebKnobManを使用した画像作成
WebKnobManは、ブラウザ上で動作するノブ画像作成ツールです。VST/AUプラグインのGUIで使用できるフィルムストリップ方式のノブ画像素材を作成できます。
- WebKnobManにアクセスする
- プリセットから好みのノブデザインを選択、またはゼロから作成する
- レイヤーやエフェクトを調整してデザインをカスタマイズ
- 「Preference」セクションで以下を指定する:
- Width/Height: 128x128ピクセル
- Rendering Frames: 128
- FrameAlign: Vertical
- "ExportTo.png"ボタンをクリックして画像を出力してダウンロードする
出力される画像は、128フレームが縦に並んだフィルムストリップ方式(例:128x16384ピクセル)になります。
解説:フィルムストリップの分解をするスクリプトをDeno/TypeScriptで作成する
WebKnobManで出力したフィルムストリップ画像を個別のフレーム画像に分解するスクリプトをDeno/TypeScriptで作成しました。
前提条件
- Deno 2.0以降
スクリプト: split_filmstrip.ts
/**
* WebKnobMan Filmstrip Splitter
*
* WebKnobManで出力したフィルムストリップ画像を個別のフレームに分解します。
*
* Usage:
* deno run --allow-read --allow-write split_filmstrip.ts <input.png> <output_dir> [frames]
*
* Arguments:
* input.png - 入力フィルムストリップ画像(WebKnobMan出力)
* output_dir - 出力先ディレクトリ
* frames - フレーム数(省略時は128)
*
* Example:
* deno run --allow-read --allow-write split_filmstrip.ts knob_filmstrip.png frames/ 128
*/
import { Image } from "https://deno.land/x/imagescript@1.3.0/mod.ts";
import { ensureDir } from "https://deno.land/std@0.224.0/fs/mod.ts";
import { parse } from "https://deno.land/std@0.224.0/flags/mod.ts";
interface SplitOptions {
inputFile: string;
outputDir: string;
numFrames: number;
prefix?: string;
}
/**
* フィルムストリップ画像を個別フレームに分解
*/
async function splitFilmstrip(options: SplitOptions): Promise<void> {
const { inputFile, outputDir, numFrames, prefix = "frame" } = options;
console.log(`🎨 Loading filmstrip: ${inputFile}`);
// 画像を読み込み
const imageData = await Deno.readFile(inputFile);
const image = await Image.decode(imageData);
const width = image.width;
const height = image.height;
const frameHeight = Math.floor(height / numFrames);
console.log(`📐 Image dimensions: ${width}x${height}`);
console.log(`🎞️ Frame dimensions: ${width}x${frameHeight}`);
console.log(`🔢 Number of frames: ${numFrames}`);
if (height % numFrames !== 0) {
console.warn(`⚠️ Warning: Image height (${height}) is not evenly divisible by frame count (${numFrames})`);
}
// 出力ディレクトリを作成
await ensureDir(outputDir);
console.log(`📁 Output directory: ${outputDir}`);
console.log("");
// 各フレームを抽出して保存
for (let i = 0; i < numFrames; i++) {
const yStart = i * frameHeight;
const yEnd = yStart + frameHeight;
// 新しい画像を作成してピクセルをコピー
const frame = new Image(width, frameHeight);
// ピクセルごとにコピー
for (let y = 0; y < frameHeight; y++) {
for (let x = 0; x < width; x++) {
const sourceY = yStart + y;
if (sourceY < height) {
const bitmap = image.bitmap;
const frameBitmap = frame.bitmap;
const sourceIndex = ((sourceY * width) + x) * 4;
const targetIndex = ((y * width) + x) * 4;
frameBitmap[targetIndex + 0] = bitmap[sourceIndex + 0]; // R
frameBitmap[targetIndex + 1] = bitmap[sourceIndex + 1]; // G
frameBitmap[targetIndex + 2] = bitmap[sourceIndex + 2]; // B
frameBitmap[targetIndex + 3] = bitmap[sourceIndex + 3]; // A
}
}
}
// ファイル名を生成
const frameNumber = String(i);
const outputFile = `${outputDir}/${prefix}_${frameNumber}.png`;
// PNG形式で保存
const encoded = await frame.encode();
await Deno.writeFile(outputFile, encoded);
// 進捗表示
if ((i + 1) % 10 === 0 || i === numFrames - 1) {
console.log(`✅ Saved ${i + 1}/${numFrames} frames`);
}
}
console.log("");
console.log(`🎉 Successfully split ${numFrames} frames to ${outputDir}`);
}
/**
* コマンドライン引数をパース
*/
function parseArgs(args: string[]): SplitOptions {
const flags = parse(args, {
string: ["prefix"],
default: {
prefix: "frame",
},
});
const [inputFile, outputDir, framesStr] = flags._;
if (!inputFile || !outputDir) {
console.error("Usage: split_filmstrip.ts <input.png> <output_dir> [frames]");
console.error("");
console.error("Arguments:");
console.error(" input.png - Input filmstrip image (WebKnobMan output)");
console.error(" output_dir - Output directory for individual frames");
console.error(" frames - Number of frames (default: 128)");
console.error("");
console.error("Options:");
console.error(" --prefix - Filename prefix for output frames (default: 'frame')");
console.error("");
console.error("Example:");
console.error(" deno run --allow-read --allow-write split_filmstrip.ts knob.png frames/ 128");
Deno.exit(1);
}
const numFrames = framesStr ? parseInt(String(framesStr), 10) : 128;
if (isNaN(numFrames) || numFrames <= 0) {
console.error(`Error: Invalid frame count: ${framesStr}`);
Deno.exit(1);
}
return {
inputFile: String(inputFile),
outputDir: String(outputDir),
numFrames,
prefix: flags.prefix,
};
}
/**
* メイン処理
*/
async function main() {
console.log("🎬 WebKnobMan Filmstrip Splitter");
console.log("================================");
console.log("");
try {
const options = parseArgs(Deno.args);
await splitFilmstrip(options);
} catch (error) {
console.error("❌ Error:", error.message);
Deno.exit(1);
}
}
// スクリプト実行
if (import.meta.main) {
main();
}
実行例
# 128フレームのフィルムストリップをファイル名のカスタムプレフィックスを指定して分解
deno run --allow-all split_filmstrip.ts knob_filmstrip.png output/ 128 --prefix=Knob
出力
スクリプトは以下のような個別のPNGファイルを生成します:
output/
├── Knob_0.png
├── Knob_1.png
├── Knob_2.png
├── ...
├── Knob_126.png
└── Knob_127.png
リソース:XML UI定義を記述する
bd_ui_loaderモジュールでは、UI構造をXMLで定義します。プログラム実行時、bd_ui_loaderはこのXML文書をパースして、JUCEコンポーネントの詳細実装を構築します。今回作成したmy_plugin_ui.xmlの内容は以下の通りです:
<?xml version="1.0" encoding="UTF-8"?>
<UI width="600" height="600">
<!-- Image component -->
<IMAGE name="background" file="Background.png"
x="0" y="0" width="600" height="600"
imageType="raster"/>
<!-- Knob component -->
<KNOB name="gain_knob"
fileNamePrefix="Knob_"
fileNameSuffix=".png"
x="200" y="200"
width="200" height="200"
numberOfFrames="128"
imageType="raster"/>
</UI>
詳細:背景画像の定義要素
IMAGEコンポーネントで背景画像を設定します。背景に使用する画像ファイルの指定やウインドウ内の座標系における配置などを以下のように定義します。
<IMAGE name="background" file="Background.png"
x="0" y="0" width="600" height="600"
imageType="raster"/>
詳細:フィルムストリップの指定方法
KNOBコンポーネントで回転ノブの仕様を定義します。ノブに使用する画像ファイルの指定やウインドウ内の座標系における配置などを以下のように定義します。この時、fileNamePrefixとfileNameSuffixを指定すると、連番画像を自動で読み込んでくれます。上の例では、Knob_0.pngからKnob_127.pngまでの128フレームが読み込まれます。numberOfFramesはノブの回転アニメーションのフレーム数です。今回は128フレームで、-135°から+135°までの270°回転を表現しています。フレーム数が多いほど、滑らかな回転になります。
<KNOB name="gain_knob"
fileNamePrefix="Knob_"
fileNameSuffix=".png"
x="200" y="200"
width="200" height="200"
numberOfFrames="128"
imageType="raster"/>
解説:PluginEditorの実装
bd_ui_loaderモジュールを使用してXMLからUI定義をロードするPluginEditorの実装は以下の通りです:
PluginEditor.h:
#pragma once
#include "PluginProcessor.h"
#include <bd_ui_loader/bd_ui_loader.h>
//==============================================================================
class AudioPluginAudioProcessorEditor final
: public juce::AudioProcessorEditor
{
public:
explicit AudioPluginAudioProcessorEditor (AudioPluginAudioProcessor&);
~AudioPluginAudioProcessorEditor() override;
//==============================================================================
void paint (juce::Graphics&) override;
void resized() override;
private:
//==============================================================================
AudioPluginAudioProcessor& processorRef;
juce::Component uiContainer;
BogrenDigital::UILoading::BinaryAssetImageLoader imageLoader;
std::unique_ptr<BogrenDigital::UILoading::UILoader> uiLoader;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (AudioPluginAudioProcessorEditor)
};
PluginEditor.cpp:
#include "PluginProcessor.h"
#include "PluginEditor.h"
#include <BinaryData.h>
//==============================================================================
AudioPluginAudioProcessorEditor::AudioPluginAudioProcessorEditor (AudioPluginAudioProcessor& p)
: AudioProcessorEditor (&p)
, processorRef (p)
, imageLoader(BinaryData::namedResourceList,
BinaryData::namedResourceListSize,
BinaryData::getNamedResource,
BinaryData::getNamedResourceOriginalFilename)
{
juce::ignoreUnused (processorRef);
addAndMakeVisible(uiContainer);
// Create UILoader with container component
uiLoader = std::make_unique<BogrenDigital::UILoading::UILoader>(uiContainer, imageLoader);
// Load the UI
uiLoader->loadUI("my_plugin_ui.xml");
// Make resizable
setResizable(true, true);
setResizeLimits(400, 300, 1600, 1200);
setSize(uiContainer.getWidth(), uiContainer.getHeight());
}
AudioPluginAudioProcessorEditor::~AudioPluginAudioProcessorEditor()
{
}
//==============================================================================
void AudioPluginAudioProcessorEditor::paint (juce::Graphics& g)
{
g.fillAll (getLookAndFeel().findColour (juce::ResizableWindow::backgroundColourId));
}
void AudioPluginAudioProcessorEditor::resized()
{
// Size the container first, then apply layout
uiContainer.setBounds(getLocalBounds());
uiLoader->applyLayout();
}
詳細:BinaryDataからリソースを取得する処理
juce_add_binary_dataで生成されるBinaryDataクラスから、こんな関数を使ってリソースにアクセスします:
-
namedResourceList: リソース名のリスト -
namedResourceListSize: リソース数 -
getNamedResource: リソースデータを取得 -
getNamedResourceOriginalFilename: 元のファイル名を取得
これらをBinaryAssetImageLoaderのコンストラクタに渡すと、XMLで指定したファイル名から適切なリソースを取ってきてくれます。
詳細:リサイズ時の処理
ウィンドウサイズが変わったら、applyLayout()でレイアウトを再適用します:
void AudioPluginAudioProcessorEditor::resized()
{
// Size the container first, then apply layout
uiContainer.setBounds(getLocalBounds());
uiLoader->applyLayout();
}
使ってみた感想
良かったところ
- C++でハードコーディングしなくてよい: UIのレイアウトをXMLで管理できるため、座標やサイズの調整がC++コードの修正なしで可能です
- フィルムストリップ方式が使える: オーディオプラグイン開発において実績のあるフィルムストリップ方式の画像素材を利用できます
-
juce::Componentとの統合が簡単:
bd_ui_loaderが設置するUI要素はjuce::Componentの実装であるため、他のJUCEネイティブのUI要素と統合が可能です - リサイズ処理の実装が実装済み: モジュール内部でピクセルのリサンプリングやレイアウトの再適用など、レスポンシブでなめらかなリサイズ処理のための実装が予め組み込まれています。ウィンドウサイズを変更しても、画像素材が適切にリサンプリングされ、各UI要素が自動で再配置されるため、開発者が複雑なリサイズロジックを実装する必要がありません
- デザイナーとの分業がしやすい: PhotoshopやFigmaからエクスポートした画像素材を組み込むといったワークフローを想定した設計になっています
気になった点
- 学習コスト: XMLスキーマとAPIの理解に時間がかかります
- デバッグしづらい: 実行時エラーが出たとき、原因を特定するのが難しいです
- 動的レイアウトは苦手: 今回作成したプロジェクトでは、単純かつ静的なGUIレイアウトでした。実際の開発においては複雑なレスポンシブ対応が求められることもあるため、そのような場合でも有用かどうかは検証を重ねる必要がありそうです
気になる点はあるものの、静的なGUIレイアウトでフォトグラフィックなUIを作りたい場合は必要十分な場合もあると思います。特に、C++のハードコーディングを減らすことは、デザイナーとプログラマーの共同作業におけるイテレーションの高速化に寄与することが期待されます。
まとめ
ADC 2025の講演で紹介されていたbd_ui_loaderを実際に触ってみて、Bogren Digitalが実践しているフォトグラフィックなプラグインUI開発のワークフローの一部を体験することができました。UI素材のレイアウトや素材の定義をC++の外側で宣言できるため、デザイナーとの共同開発や高速なプロトタイピングに役立つと思います。
今回はBinaryDataに埋め込んだXMLを使いましたが、外部XMLファイルから読み込めるようにすると、もっと開発が捗りそうです。例えば、プログラム実行中にXMLファイルを編集したら、動的にXMLを再ロードしてレイアウトを更新するといった、ホットリロードの仕組みを追加すると面白いと思います。


