はじめに
本稿は,JUCE Advent Calendar 2017の6日目分の投稿になります.
2017年7月にリリースされたJUCE 5.1にて新たにDSP moduleというmoduleが追加されました.このDSP moduleを利用することで,フィルタリング,オーバーサンプリング,任意のIRを用いた畳み込み等の処理を入出力信号に対して簡単に施すことができます.今回は,簡単なディストーションVSTプラグインの作製を通じて,DSP moduleの基本的な使い方を見ていこうと思います.
コーディングの前に
プロジェクト作成時にデフォルトの状態だとdsp moduleが有効化されていないので.使用moduleに追加しておきます.また,dsp moduleはC++14が必須なので,こちらの設定もしておきます.
歪み処理の実装
まずは,入力信号を歪ませるための処理を実装しながらDSP moduleの基本的な使い方を見ていきます.今から実装するのは,以下のようなシステムになります.
入力信号を増幅させ,適当な関数でクリッピングさせることで信号を歪ませることにします.この信号増幅とクリッピングという処理をそれぞれ,Gain,WaveShaperというDSP moduleを使って実装します.
DSP moduleに含まれるクラスは基本的にテンプレート化されており,テンプレート引数には基本的に計算精度を指定する形となっています.従って,大体はfloatまたはdoubleを指定しておけば大丈夫です(例外についてはその都度,説明します).また,DSP moduleは,dspという名前空間が与えられていますが,本文中では省略していきます.
では,実際にソースコードを見ながらこれらの使い方を見ていきます.
データメンバの宣言と初期化
class StaticClipperVstAudioProcessor : public AudioProcessor
{
public:
/*...*/
//Parameterの用意[1]
enum Parameters {
Drive = 0,
OutVol = 1,
TotalNumParams = 2
};
AudioParameterFloat* parameters[Parameters::TotalNumParams];
//歪み用の関数[2]
static float clipping(float in) {
auto threshold = std::tanhf(in);
auto out = in;
if (abs(in) > abs(threshold)) { out = threshold; }
return out;
};
private:
//信号処理を行うDSP moduleクラスオブジェクトを用意[3]
dsp::WaveShaper<float> clipper;
dsp::Gain<float> drive, outVol;
//[3]のオブジェクトの初期化に必要な情報を保持する構造体[4]
dsp::ProcessSpec spec;
/*...*/
};
- [2]: 入力信号をクリッピングさせる関数です.JUCEにおいてオーディオ信号は[-1.0, 1.0]の範囲で扱うのが基本なので,tanh等のシグモイド関数を使うと便利です.静的でないメンバ関数を使用したい場合は,自前でDSP moduleをいじる必要があるので今回は割愛します.
- [3]: WaveShaper,Gainはそれぞれ,歪み処理,増幅・減衰処理を信号データに対して施すことができます.これらのように,実際に信号処理を行うためのDSP moduleクラスを今後は,"processor"と呼称します.
//コンストラクタ[5]
StaticClipperVstAudioProcessor::StaticClipperVstAudioProcessor()
: AudioProcessor (BusesProperties()
#if ! JucePlugin_IsMidiEffect
#if ! JucePlugin_IsSynth
.withInput ("Input", AudioChannelSet::stereo(), true)
#endif
.withOutput ("Output", AudioChannelSet::stereo(), true)
#endif
),
parameters{
new AudioParameterFloat("DRV", "Drive", 0.f, 30.f, 12.f),
new AudioParameterFloat("OV", "OutVol", -36.f, 6.f, -12.f)
},
clipper(), drive(), outVol()
{
for (int i = 0; i < Parameters::TotalNumParams; i++) {
addParameter(parameters[i]);
}
}
/*...*/
//processorの初期化[6]
void StaticClipperVstAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
spec.sampleRate = sampleRate;
spec.numChannels = 2;
spec.maximumBlockSize = samplesPerBlock;
clipper.prepare(spec);
drive.prepare(spec);
outVol.prepare(spec);
}
- [6]: オーディオ再生の準備が整った時点で,ProcessSpecを満たすのに必要な情報が揃います.ProcessSpecに必要情報を保持させ,各processorが共通で備えている初期化用関数
void prepare(const ProcessSpec&) noexcept
を実行することで,processorの初期化が完了します.
補足: processorの初期化について
prepareメンバ関数は全てのprocessorが備えているため,processorの初期化はとりあえずprepareを呼ぶと覚えてよいかと思います.また,各processorが共通に備えている関数としてvoid reset() noexcept
という関数もあります.多くのprocessorではprepareが実行された際に,resetも実行されるようになっています.具体的な初期化処理については,processorによってまちまちなので,ざっとソースコードに目を通しておくとよいかと思います.
余談ですが,prepareのインターフェイスはvoid prepare(const ProcessSpec&) noexcept
となっており,初期化には必ずProcessSpecの情報が必要であると思われますが,processorによってはこの情報を全く必要としないものもあります.実際,例えばWaveShaper processorのprepareは空実装となっています.WaveShaper::prepare (ProcessSpec&) noexcept {};
.
ProcessBlockの実装
void StaticClipperVstAudioProcessor::processBlock (AudioSampleBuffer& buffer, MidiBuffer& midiMessages)
{
ScopedNoDenormals noDenormals;
auto totalNumInputChannel = getTotalNumInputChannels();
auto totalNumOutputChannel = getTotalNumOutputChannels();
auto bufferSize = buffer.getNumSamples();
for (int i = totalNumInputChannel; i < totalNumOutputChannel; i++) {
buffer.clear(i, 0, bufferSize);
}
//各processorのパラメータ(振る舞い)を設定[7]
clipper.functionToUse = clipping;
drive.setGainDecibels(parameters[Parameters::Drive]->get());
outVol.setGainDecibels(parameters[Parameters::OutVol]->get());
//入力信号バッファをラップ[8]
dsp::AudioBlock<float> audioBlock(buffer);
dsp::ProcessContextReplacing<float> context(audioBlock);
//processorによる信号処理[9]
drive.process(context);
clipper.process(context);
outVol.process(context);
}
- [7]: 各processorのパラメータ(歪み用関数の形状,増幅量)を更新しています.パラメータ更新のやり方は,processorによってまちまちなのでそれぞれのドキュメントやソースコードを参照してください.WaveShaper processorの場合は,processorに定義されている関数ポインタメンバ変数
functionToUse
に歪み用の関数ポインタ[2]を渡します.Gain processorは,増幅量を設定するメンバ関数setGainDecibels
が定義されているのでこちらを利用します.
補足: AudioBlockとProcessContextReplacingは何者なのか
AudioBlockはチャンネル数 * 時系列信号データからなる二次元データ配列(オーディオバッファ)のポインタを保持します.また,オーディオバッファの先頭ポインタやチャンネル数 (行サイズ),バッファサイズ(列サイズ)を取得する等オーディオバッファを操作するメンバ関数を備えています.
今回は,ProcessBlockの引数で受け取るAudioSampleBuffer& buffer
からAudioBlockを生成しています.bufferは既にホスト・VST間でやりとりされるオーディオバッファ(のポインタ)を保持しているので,8のAudioBlockもまた同じものを保持することになります.また,自分で定義した適当な浮動小数点型の2次元データ配列を新たなオーディオバッファとし,AudioBlockを生成することも可能です.
processorでオーディオバッファを処理するには,AudioBlockをProcessContextReplacingでさらにラップしてからprocessorに渡す必要があります.ProcessContextReplacingは,渡されたAudioBlockの参照を保持しており,各processorのprocess関数では,ProcessContextReplacingのインターフェイスを介して,オーディオバッファのデータを取得・処理をするようになっています.AudioBlockをラップしてprocessorに渡すためのアダプタ(のようなもの)としては,もう一つ,ProcessContextNonReplacingというクラスが用意されており,それぞれ,用途が異なるのですが,詳細は公式ドキュメント等を参照してください.
周波数フィルタを噛ませてみる
続いて,以下のようにプラグインの実装を拡張してみます.
歪み処理部の前後に周波数フィルタを置いてみました.前段はローシェルフフィルタ(LSF),後段は,ローパスフィルタ(LPF)を置くことにします.ここで新たに使用するDSP moduleは,IIR:Filter, IIR::Ceffients, ProcessorDuplicatorの3つのクラスになります.まずは,前段のLSFから実装していきます.
LSFの実装
class StaticClipperVstAudioProcessor : public AudioProcessor
{
public:
/*...*/
//Parameterの用意[1]'
enum Parameters {
Drive = 0,
OutVol = 1,
LsfGain = 2,
TotalNumParams = 3
};
/*...*/
private:
//Filterをマルチチャンネル仕様にする[10]
using StLsf = dsp::ProcessorDuplicator<dsp::IIR::Filter<float>, dsp::IIR::Coefficients<float>>;
//信号処理を行うDSP moduleクラスオブジェクトを用意[3]'
/*...*/
StLsf lsf;
/*...*/
};
- 1': LSFのパラメータを追加します.今回はカットオフ周波数は固定で,ゲインのみを調整できるようにします.
- [10]: フィルタリングを行うFilter processorは,モノラルオーディオバッファにしか対応していません.従って,これをマルチチャンネルに対応させるProcessorDuplicatorという別のprocessorでラップする必要があります.ProcessorDuplicatorの第一テンプレート引数はモノラルprocessorの型,第二引数には,モノラルprocessorの振る舞いを決めるパラメータを保持するクラスの型を指定します.今回の場合,後者はCoefficientsクラスで,フィルタの性質を決定づけるフィルタ係数という値を保持します.
//コンストラクタ[5]'
StaticClipperVstAudioProcessor::StaticClipperVstAudioProcessor()
: /*...*/,
parameters{
new AudioParameterFloat("DRV", "Drive", 0.f, 30.f, 12.f),
new AudioParameterFloat("OV", "OutVol", -36.f, 6.f, -12.f),
new AudioParameterFloat("LSG", "Pre-LSF Gain [dB]", -12.f, 12.f, 0.f),
},
clipper(), drive(), outVol(), lsf()
{
/*...*/
}
//processorの初期化[6]'
void StaticClipperVstAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
/*...*/
lsf.prepare(spec);
}
- [3]', 5', [6]': Filter processorをラップしたProcessorDuplicator := StLsfもprocessorなので,基本的に先の歪み処理の実装で使用したprocessorと同じように扱えます.1'で新たに追加したLSFのゲイン調整パラメータの初期化し,StLsfは,他のprocessorと同様にデフォルトコンストラクタを用いて初期化するようにしています.
void StaticClipperVstAudioProcessor::processBlock (AudioSampleBuffer& buffer, MidiBuffer& midiMessages)
{
/*...*/
//各processorのパラメータ(振る舞い)を設定[7]'
clipper.functionToUse = clipping;
*lsf.state = *dsp::IIR::Coefficients<float>::makeLowShelf(spec.sampleRate, 300.f, 0.5f, Decibels::decibelsToGain(parameters[Parameters::LsfGain]->get()));
drive.setGainDecibels(parameters[Parameters::Drive]->get());
outVol.setGainDecibels(parameters[Parameters::OutVol]->get());
//入力信号バッファをラップ[8]
/*...*/
//processorによる信号処理[9]'
lsf.process(context);
drive.process(context);
clipper.process(context);
outVol.process(context);
}
-[7]': LSFのパラメータを設定しています.フィルターの性質は,フィルタ係数という値によって決まり,そのフィルタ係数を保持するCefficientsクラスのインスタンスをここで生成,LSFに渡しています.詳しくは,下記の補足を見てください.
補足: ProcessorDuplicatorは何者なのか
[10]にて説明したようにFilter processorは,そのままではモノラルバッファしか処理できません.複数チャンネル処理する場合は,処理すべきチャンネル数分のprocessorインスタンスを用意する必要があります.このあたりの処理をうまいことやってくれるのがProcessorDuplicatorになります.ここでは,その振る舞いを少し詳しく見ていきます.下記にProcessorDuplicatorの実装を一部抜粋しましたので,これを基に説明していきます.
//今回の場合(StLsf[10])MonoProcessor = Filter, StateType = Coefficientsとなる[a]
template <typename MonoProcessorType, typename StateType>
struct ProcessorDuplicator
{
//デフォルトコンストラクタ.空のCoeffientsが生成される[b]
ProcessorDuplicator() : state (new StateType()) {}
/*...*/
//必要なチャンネル数分だけFilter processorをインスタンス化[c]
void prepare (const ProcessSpec& spec)
{
processors.removeRange ((int) spec.numChannels, processors.size());
while (static_cast<size_t> (processors.size()) < spec.numChannels)
//Filter processorにCoeffientsを紐づける形でインスタンス化[d]
processors.add (new MonoProcessorType (state));
auto monoSpec = spec;
monoSpec.numChannels = 1;
for (auto* p : processors)
p->prepare (monoSpec);
}
/*...*/
//Coeffientsの参照カウント型スマートポインタ(RCSP)を保持.外からアクセスできる.[e]
typename StateType::Ptr state;
private:
/*...*/
//Filter processorインスタンスをチャンネル数分だけ保持するコンテナ[f]
juce::OwnedArray<MonoProcessorType> processors;
};
まず,5'にて,StLsf[10]のデフォルトコンストラクタ[a]が呼ばれ,空のCoeffientsが1つ生成,そのRCSPがStLsfに保持されます.Coeffientsクラスには,using Ptr = ReferenceCountedObjectPtr<Coefficients>
が定義されているので,[e]の型にマッチします(ReferenceCountedObjectPtrは,JUCE独自のRCSP).この時点では,まだFilter processorはインスタンス化されていません.
続いて,[6]'でStLsfのprepareが実行され,必要チャンネル数分だけFilter Processorが用意されます.[d]を見るとわかるように,各Filter processorは,1つのパラメータ(Coefficiens)を共有する形になります.この時点では,まだ,パラメータ[e]の具体的な状態が定まっていないので,各フィルタの動作も定まっていない状態になります.現時点でのStLsfの状態は以下のようなイメージになります.
[7]'にてStLsfのフィルタ係数が定まります.*lsf.state = *dsp::IIR::Coefficients<float>::makeLowShelf(samplerate, cutoffFreq, Q, gain)
の動作を順に追っていくと.まず,右辺のCoefficients<float>::makeLowShelf(samplerate, cutoffFreq, Q, gain)
は,Coeffientsクラスに定義されている静的メンバ関数で,帰り値はPtr
となっています.これによって,指定したカットオフ周波数,Q値,gainを持つ2次のローシェルフ形状を示すフィルタ係数を保持するCoeffientsのRCSPが生成されます.そして,これをStLsfが保持するCoeffientsに代入することによって,StLsfのCoeffientsの値が更新されます(逆参照による代入なのでRCSPではなく,それが指す実態がコピーされている点に注意).StLsfのCoeffients(のRCSP)は,StLsfの各Filter processorと紐づいているので[d],この時点で,すべてのFilter processor[f]の振る舞いが確定します.
LPFの実装
では,続いて後段のローパスフィルタ (LPF)を実装していきます.今回実装するLPFは以下のようなMOOGラダーフィルタを模したものを実装してみます.
1次系のローパスフィルタ(H1)を4つ直列に繋ぎ,負のフィードバック結合を持たせています.しかし,このような処理系を持つprocessorはDSP moduleに備わっていないので自前で実装していきます.
自前のprocessorを実装する
今回実装したいのはフィルタ処理を行うprocessorなので,先のProcessorDuplicatorに対応できるようなモノラルprocessorをデザインする方向で考えてみます.そうすると,信号処理を行うprocessor本体とそのパラメータを保持するクラス(以後,これをstateクラスと呼称)を別々に用意する必要がありそうです.
processorのstateクラスを用意する
先のLSFの実装では,Coeffientsクラスがstateクラスの役割を担っていました.従って,これを参考に自前のLPF processor用のstateクラスをデザインすればよさそうです.このような方針のもと設計したstateクラスが以下になります.
//RefarenceCountedObjectPtrを継承しているProcessorStateを継承[11]
template <typename FloatType>
struct LpfState: public dsp::ProcessorState {
LpfState()
:coefficients(new dsp::IIR::Coefficients<FloatType>())
{};
//ProcessorDuplicatorは,stateクラスをnewして,そのPtrを保持する[12]
using Ptr = ReferenceCountedObjectPtr<LpfState<FloatType>>;
void set(double fs, FloatType cutoff, FloatType feedback) {
*coefficients = *dsp::IIR::Coefficients<FloatType>::makeFirstOrderLowPass(fs, cutoff);
this->feedback = feedback;
}
//自作LPF processorのパラメータ[13]
FloatType feedback;
typename dsp::IIR::Coefficients<FloatType>::Ptr coefficients;
};
- [11], [12]: ProcessorDuplicatorは,Ptrというエイリアスが与えられたstateクラスのRCSPを保持するようになっているので,ここで定義しておきます.
- [13]: 自作LPF processorでは,内部に4つの1次系LPFを持たせるので,そのフィルタ係数を保持するCoeffientsクラスが必要です(4つの1次系LPFの特性はすべて同じにするので,フィルタ係数のセットは1つでよい).また,feedback量についても,processorのパラメータなのでstateクラスに保持させます.
processorを用意する
続いて,LPF processor本体を用意していきましょう.こちらも,先のLSFや他のprocessorを参考にその実装を考えていきます.まず,他のprocessorには,prepare, reset, processというメンバ関数が共通して実装されているので,これらは自作のLPF processorにも実装してやります.
また,先に設計したstateクラスであるLpfStateをprocessorに渡す方法も考える必要があります.これについては,processorのコンストラクタ引数として,LpfStateのポインタを渡すようにすればよさそうです.以上のことを踏まえて,実装したLPF processorが以下になります.
template <typename FloatType>
class FbLpf {
public:
//コンストラクタでLpfStateのポインタを受け取り,内部に保持する[14]
FbLpf(LpfState<FloatType>* ptr)
:paramPtr(ptr), feedbackSig()
{
//4つの1次系LPFを用意.これらの状態を左右するCoeffientsはLpfStateより提供される[15]
for (int i = 0; i < 4; i++) { filters.push_back(dsp::IIR::Filter<FloatType>(ptr->coefficients)); }
};
//prepare, resetは各1次系LPFについて作用させる[16]
void prepare(const dsp::ProcessSpec& spec) noexcept {
for (auto& filt : filters) { filt.prepare(spec); }
};
void reset() {
for (auto& filt : filters) { filt.reset(); }
};
template <typename ProcessContext>
void process(const ProcessContext& context) noexcept {
auto&& inputBlock = context.getInputBlock();
auto&& outputBlock = context.getOutputBlock();
jassert(inputBlock.getNumChannels() == 1);
jassert(outputBlock.getNumChannels() == 1);
auto numSamples = inputBlock.getNumSamples();
auto* src = inputBlock.getChannelPointer(0);
auto* dst = outputBlock.getChannelPointer(0);
auto feedbackAmt = paramPtr->feedback;
//1 sampleずつ4つの1次系LPFで連続的に処理し,その出力を1遅延させて入力へフィードバック[17]
for (auto i = 0; i < numSamples; i++) {
auto data = src[i] - feedbackSig;
for (auto& filter : filters) {
data = filter.processSample(data);
}
feedbackSig = data * feedbackAmt;
dst[i] = data;
}
for (auto& filter : filters) { filter.snapToZero(); }
};
private:
typename LpfState<FloatType>::Ptr paramPtr;
std::vector<dsp::IIR::Filter<FloatType>> filters;
FloatType feedbackSig;
};
- [14][15]: 4つのFilter processorをコンストラクションする際に,LpfStateに保持しているCoeffientsのRCSPを渡します.これで,LpfStateのCoeffientsが更新された時,各Filterの状態も同時に更新されます.
- [17]: Filter processorには,1サンプルだけフィルタ処理し,その値を返すメンバ関数
processSample(FloatType sample)
が実装されているので,これを利用しています.
自作Processorを組み込む
自作したprocessorをPluginProcessorに組み込みます,ProcessorDuplicatorでラップして使用するので,LSFの時とほとんど同じ形になります.
class StaticClipperVstAudioProcessor : public AudioProcessor
{
public:
/*...*/
//Parameterの用意[1]''
enum Parameters {
Drive = 0,
OutVol = 1,
LsfGain = 2,
LpfFreq = 3,
LpfFeedback = 4,
TotalNumParams = 5
};
/*...*/
private:
//Filterをマルチチャンネル仕様にする[10]'
/*...*/
using StLpf = dsp::ProcessorDuplicator<FbLpf<float>, LpfState<float>>;
/*...*/
//信号処理を行うDSP moduleクラスオブジェクトを用意[3]''
/*...*/
StLpf lpf;
/*...*/
};
//コンストラクタ[5]''
StaticClipperVstAudioProcessor::StaticClipperVstAudioProcessor()
: /*...*/,
parameters{
new AudioParameterFloat("DRV", "Drive", 0.f, 30.f, 12.f),
new AudioParameterFloat("OV", "OutVol", -36.f, 6.f, -12.f),
new AudioParameterFloat("LSG", "Pre-LSF Gain [dB]", -12.f, 12.f, 0.f),
new AudioParameterFloat("LPC", "Post-LPF Cutoff [Hz]", 200.f, 20000.f, 15000.f),
new AudioParameterFloat("LPFD", "Post-LPF Feedback", 0.f, 1.f, 0.f)
},
clipper(), drive(), outVol(), lsf(), lpf()
{
/*...*/
}
//processorの初期化[6]''
void StaticClipperVstAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
/*...*/
lpf.prepare(spec);
}
void StaticClipperVstAudioProcessor::processBlock (AudioSampleBuffer& buffer, MidiBuffer& midiMessages)
{
/*...*/
//各processorのパラメータ(振る舞い)を設定[7]''
clipper.functionToUse = clipping;
drive.setGainDecibels(parameters[Parameters::Drive]->get());
outVol.setGainDecibels(parameters[Parameters::OutVol]->get());
*lsf.state = *dsp::IIR::Coefficients<float>::makeLowShelf(spec.sampleRate, 300.f, 0.5f, Decibels::decibelsToGain(parameters[Parameters::LsfGain]->get()));
lpf.state->set(spec.sampleRate, parameters[Parameters::LpfFreq]->get(), parameters[Parameters::LpfFeedback]->get());
//入力信号バッファをラップ[8]
dsp::AudioBlock<float> audioBlock(buffer);
dsp::ProcessContextReplacing<float> context(audioBlock);
//processorによる信号処理[9]''
lsf.process(context);
drive.process(context);
clipper.process(context);
lpf.process(context);
outVol.process(context);
}
とりあえず完成
これで信号処理部分は完成です.おまけとして,GUIと歪み量のモニタリング機能をつけたもののソースコードと,ビルド済みVST(2.4)を下記においておきます.
冒頭に述べたように,DSP moduleには,他にもオーバーサンプリングや外部から読み込んだIRを使用しての畳み込み処理等をサポートする便利なprocessorがあります.この辺りの使い方は,公式のサンプルプロジェクトが参考になります.その他にも,高次フィルタの設計や波形テーブルの作製をサポートしてくれる機能もあったりと便利さが満載なので,ぜひ,お試しあれ.