本記事は JUCE Advent Calendar 2021 の12月17日向けに投稿した記事です。
はじめに
こんにちは、Takacie です。
12月は忙しくてJUCE Advent Calendar 2021は1ネタしか書けないなと思っていたのですが、余裕ができたのでもう一個書くことにしました。初参加の割に気合十分です。よろしくお願いします。
今回は juce::ParameterAttachment について書こうと思います。
juce::ParameterAttachment とは?
juce::ParameterAttachment を簡潔に説明すると、「GUI要素とパラメーターをいい感じにコネクトしてくれるやつ」です。
juce::Component を基底としたクラスを継承して新たなGUI要素を作成する際に、 juce::ParameterAttachment をデータメンバーとして入れてやります。そして、GUI要素を操作するマウスイベントなどの処理の中で juce::ParameterAttachment のメソッドを実行すると、パラメーターに変更が反映されます。このとき、 juce::ParameterAttachment の初期化時にコンストラクタの引数に入れたラムダ式が呼ばれるような設計になっています。これを使えば、1つのコンポーネント上で、データメンバーにある juce::ParameterAttachment の数だけパラメーターを操作することができます。素敵すぎます...!
文章でタラタラ説明しててもしょうがないので、次節で実際にコードを見ながら解説していきます。
作ってみる
例として「親コンポーネント内の X, Y 平面上をドラッグで移動させ、その位置によってパラメーターを更新するコンポーネント」を作成してみたいと思います。親コンポーネントの左下を原点 (0, 0) として、右に移動させると X 軸を担当するパラメーターが増加し、上に移動させると Y 軸を担当するパラメーターが増加するような仕様にします。
パラメーター(プロセッサー側)
プロセッサー側で、 X 軸、Y 軸を担当するパラメーターを作ります。あくまで一例なので、プロセッサーにこれらのパラメーターを登録する方法は各々に任せます。分からない方は以下のページが参考になると思います。
AudioProcessorValueTreeState::ParameterLayout params(
std::make_unique<AudioParameterFloat>(
"X",
"X",
NormalisableRange<float>(0.0f, 200.0f),
100.0f,
String(),
AudioProcessorParameter::genericParameter,
nullptr,
nullptr
),
std::make_unique<AudioParameterFloat>(
"Y",
"Y",
NormalisableRange<float>(0.0f, 100.0f),
50.0f,
String(),
AudioProcessorParameter::genericParameter,
nullptr,
nullptr
)
);
X 軸が [0.0, 200.0] で、 Y 軸が [0.0, 100.0] なので、このコンポーネントを親コンポーネントの一番右上に移動させるとパラメーターは X: 200.0, Y: 100.0 となります。
全体コード
まずは全体のコードになります。
全体コード(クリックで展開されます)
#pragma once
#include "juce_audio_processors/juce_audio_processors.h"
#include "juce_gui_basics/juce_gui_basics.h"
using namespace juce;
// aliase
using APVTS = AudioProcessorValueTreeState;
using CBC = ComponentBoundsConstrainer;
// constants
template<class T> constexpr float floatCast(T num) { return static_cast<float>(num); }
template<class T> constexpr float clamp0To1(T num) { return std::clamp(num, 0.0f, 1.0f); }
// PA_Sample class
class PA_Sample : public Component
{
public:
// constructor
PA_Sample(const APVTS& apvts, const StringRef& paramIdX, const StringRef& paramIdY) :
apvts(apvts),
paramX(*apvts.getParameter(paramIdX)),
paramY(*apvts.getParameter(paramIdY))
{
}
// override
void paint(Graphics& g) override
{
g.fillAll(Colours::white);
}
void parentHierarchyChanged() override
{
attachmentX.sendInitialUpdate();
attachmentY.sendInitialUpdate();
}
void mouseDown(const MouseEvent& event) override
{
isEditing = true;
attachmentX.beginGesture();
attachmentY.beginGesture();
dragger.startDraggingComponent(this, event);
}
void mouseDrag(const MouseEvent& event) override
{
const int halfW = getWidth() / 2;
const int halfH = getHeight() / 2;
constrainer.setMinimumOnscreenAmounts(halfH, halfW, halfH, halfW);
dragger.dragComponent(this, event, &constrainer);
const auto pos = getPosition();
const float valueX = floatCast(pos.getX()) / getParentWidth();
const float valueY = 1.0f - floatCast(pos.getY()) / getParentHeight();
const float denormX = rangeX.convertFrom0to1(clamp0To1(valueX));
const float denormY = rangeY.convertFrom0to1(clamp0To1(valueY));
attachmentX.setValueAsPartOfGesture(denormX);
attachmentY.setValueAsPartOfGesture(denormY);
// getParentComponent()->repaint();
}
void mouseUp(const MouseEvent& event) override
{
isEditing = false;
attachmentX.endGesture();
attachmentY.endGesture();
}
private:
// member
const APVTS& apvts;
RangedAudioParameter& paramX;
RangedAudioParameter& paramY;
ParameterAttachment attachmentX { paramX, [&](float v){setValueX(v);}, nullptr };
ParameterAttachment attachmentY { paramY, [&](float v){setValueY(v);}, nullptr };
NormalisableRange<float> rangeX { paramX.getNormalisableRange() };
NormalisableRange<float> rangeY { paramY.getNormalisableRange() };
ComponentDragger dragger;
CBC constrainer;
bool isEditing = false;
// helper
void setValueX(float newValue)
{
if (!isEditing) {
const float normX = rangeX.convertTo0to1(newValue);
const int x = getParentWidth() * normX;
setTopLeftPosition(x, getY());
repaint();
}
}
void setValueY(float newValue)
{
if (!isEditing) {
const float normY = rangeY.convertTo0to1(newValue);
const int y = getParentHeight() * (1.0f - normY);
setTopLeftPosition(getX(), y);
repaint();
}
}
};
コード解説
最初にコンストラクタとデータメンバーを見てみましょう。
PA_Sample(const APVTS& apvts, const StringRef& paramIdX, const StringRef& paramIdY) :
apvts(apvts),
paramX(*apvts.getParameter(paramIdX)),
paramY(*apvts.getParameter(paramIdY))
{
}
// プロセッサ側から持ってくる juce::AudioProcessorValueTreeState
const APVTS& apvts;
// X軸、Y軸をそれぞれ担当するパラメーター
RangedAudioParameter& paramX;
RangedAudioParameter& paramY;
// 今回の主役
ParameterAttachment attachmentX { paramX, [&](float v){setValueX(v);}, nullptr };
ParameterAttachment attachmentY { paramY, [&](float v){setValueY(v);}, nullptr };
// なくても良いが、あったほうがやりやすい(主にノーマライズ・デノーマライズで超楽になる)
NormalisableRange<float> rangeX { paramX.getNormalisableRange() };
NormalisableRange<float> rangeY { paramY.getNormalisableRange() };
// ドラッグに対応させるためのメンバー
ComponentDragger dragger;
CBC constrainer;
bool isEditing = false;
コンストラクタでは、プロセッサ側から持ってくる juce::AudioProcessorValueTreeState の参照 apvts
の初期化を行い、それに伴い paramX
、 paramY
が X, Y 軸を担当するパラメーターの ID を受け取って初期化されます。
次に 今回の主役である juce::ParameterAttachment 型の attachmentX
、 attachmentY
が初期化されます。引数は以下のようになります。
- 第一引数: 連動させたいパラメーター(
RangedAudioParameter&
型) - 第二引数: パラメーターに変更があった際に呼び出される関数(
std::function<void(float)>
型) - 第三引数: UndoManager(割愛)
第二引数に渡す関数は以下のように定義しました。
void setValueX(float newValue)
{
if (!isEditing) {
const float normX = rangeX.convertTo0to1(newValue);
const int x = getParentWidth() * normX;
setTopLeftPosition(x, getY());
repaint();
}
}
void setValueY(float newValue)
{
if (!isEditing) {
const float normY = rangeY.convertTo0to1(newValue);
const int y = getParentHeight() * (1.0f - normY);
setTopLeftPosition(getX(), y);
repaint();
}
}
引数の newValue
には、パラメーター変更後の値が渡されます。つまり、 X 軸の方は [0.0, 200.0] 、 Y 軸の方は [0.0, 100.0] の範囲の値が渡されることになります。
isEditing
で制御しているのは、ドラッグでコンポーネントを移動している間は、 setTopLeftPosition(int, int)
でのコンポーネントの強制移動をさせたくなかったためです。マウスイベントで true
/false
を切り替えています。
マウスイベントの定義は以下のようになっています。
void mouseDown(const MouseEvent& event) override
{
isEditing = true;
attachmentX.beginGesture();
attachmentY.beginGesture();
// ドラッグ対応化のための処理
dragger.startDraggingComponent(this, event);
}
void mouseDrag(const MouseEvent& event) override
{
// ドラッグ対応化のための処理
const int halfW = getWidth() / 2;
const int halfH = getHeight() / 2;
constrainer.setMinimumOnscreenAmounts(halfH, halfW, halfH, halfW);
dragger.dragComponent(this, event, &constrainer);
const Rectangle<int> pos = getPosition(); // 移動先の座標
const float valueX = floatCast(pos.getX()) / getParentWidth(); // 親コンポーネント内でのX座標の割合 [0.0, 1.0]
const float valueY = 1.0f - floatCast(pos.getY()) / getParentHeight(); // 親コンポーネント内でのY座標の割合 [0.0, 1.0](本来はYの増加方向が逆なので1.0fから引いている)
const float denormX = rangeX.convertFrom0to1(clamp0To1(valueX)); // X 軸担当のパラメーターに渡す値に変換
const float denormY = rangeY.convertFrom0to1(clamp0To1(valueY)); // Y 軸担当のパラメーターに渡す値に変換
attachmentX.setValueAsPartOfGesture(denormX);
attachmentY.setValueAsPartOfGesture(denormY);
// 実行環境によっては、ドラッグ移動中に描画が間に合わなくて見切れてしまう場合があるので
// 気になるならコメントアウトを外す
// getParentComponent()->repaint();
}
void mouseUp(const MouseEvent& event) override
{
isEditing = false;
attachmentX.endGesture();
attachmentY.endGesture();
}
mouseDown(const MouseEvent&)
メソッドで、 ParameterAttachment::beginGesture()
を呼び出して、 ParameterAttachment を使ったパラメーターの変更を開始することをメッセージスレッドに知らせています。
mouseDrag(const MouseEvent&)
メソッドでは、ドラッグでコンポーネントを移動した先の座標から、パラメーターに渡す値を計算して、 ParameterAttachment::setValueAsPartOfGesture(float)
で渡しています。
mouseUp(const MouseEvent&)
メソッドで、 ParameterAttachment::endGesture()
を呼び出して、 ParameterAttachment を使ったパラメーターの変更を終了することをメッセージスレッドに知らせています。
mouseDrag(const MouseEvent&)
内で呼び出しているのは ParameterAttachment::setValueAsPartOfGesture(float)
ですが、これはスライダーなどの連続した値を扱う際に使います。ボタンなどの、状態が2個しか無いようなものには、 ParameterAttachment::setValueAsCompleteGesture(float)
を使うと良いでしょう。
最後に、後回しにしていた重要な部分です。
void parentHierarchyChanged() override
{
attachmentX.sendInitialUpdate();
attachmentY.sendInitialUpdate();
}
ParameterAttachment::sendInitialUpdate()
で、現在のパラメーターの値を反映しています。強制的に setValueX(float)
、 setValueY(float)
が呼び出されるような形になります。
なぜ parentHierarchyChanged()
をオーバーライドして呼び出しているのかというと、パラメーターの更新に親コンポーネントの幅、高さを使用しているという特性上、それらが確定していない段階(このコンポーネントのコンストラクタ)で呼び出すと、 setValueX(float)
、 setValueY(float)
に望んでいない値が渡されるからです。場合によってはコンストラクタなどで呼び出しても大丈夫なはずなので、状況に応じて呼び出す場所を工夫しましょう。
成果物
最終的にはこのようなコンポーネントになります。GUI要素⇨パラメーターへの反映です。
さいごに
juce::ParameterAttachmentについての日本語の解説がなかなか見つからなかったので、国内JUCErの皆さんのお役に立てるかと思います。
これからこれを使ってEqualizerを作ろうと思っていて、その過程も【How to JUCE長編】としてQiitaに投稿する予定なのでご期待ください!