この記事はtraP Advent Calendar 2021 26日目の記事です。
お前は誰だ
19Bのリキッドです。普段はサウンド班や、個人サークル「華力発電所」などで音楽を作るなどしています。
プラグインを作ろう
今日お話するのは、PCベースの楽曲制作においては欠かすことのできない、VSTプラグインについてです。
VSTプラグインは、独・Steinberg社が開発したVST SDKを用いて、C++によって作ることができます。VST SDKを入手することで、誰でもプラグインを開発することができます。
とは言うものの、素の状態でもだいぶ厄介なC++の扱いに加え、オーディオ信号を処理するための知識や、VST SDKを扱うためにドキュメントやら何やらを読みまくるのは大変な作業です。この大変な作業をだいぶ楽にしてくれるライブラリ、それがJUCEです。

詳しいことは、昨年のAdCのこの記事を読んでみてください。

Stereo Panってなんだ
通常DAWなどの各トラックに備え付けのPanは、左右チャンネルの音量を調整することで、音源の定位を操作しています。モノラル音声に対してはこの操作で何の問題もないのですが、ステレオ音声に関しては話が変わってきます。ステレオ音声の左右の音量バランスを変えてしまうと、ステレオイメージが崩れてしまいます。例として以下のような音源を用意しました。
- 元のステレオ音源
- 普通のPan
これでは、ドラムの音声が右に回転した、とは言えません。この問題を解決するのが、Stereo Panです。
類似品はいねが~
あります。有名所は、Waves 「S1 Imager」、Boz Digital Labs 「Pan Knob」、A.O.M. 「Cyclic Panner」などでしょうか。S1は持っているのですが、ちょいと時代遅れ、Cyclic Pannerは大人気製品のようですが、如何せん高い。Bozはよく知らないです。
そういうわけで、勉強がてら自分で作ってしまおうと思ったわけです。
作った
こんな感じに出来ました。

論より証拠、音を聞いてもらいましょう。
- 通常のPan
- LPanner
かなりいい感じじゃないですか?ではこのLPannerがどのように作られたかを見ていきましょう。
Stereo Panの理論的解説
L Channelの信号を、R Channelの信号をとします。このとき、音源のMid成分(またはMono成分)およびSide成分(またはStereo成分)は、それぞれ
と表せます。コイツらの成すステレオ音場を左にだけ回転させることを考えると、回転後のMS信号およびは、
となります。最後にこのMS信号をLR信号に戻してやれば、出力及びは、
となります。これをC++とJuceで実装してやれば良いわけです。
実装した
ここからは実際のソースを見ながら解説します。もともと人に見せる予定はなかったので、クッソ汚いですがご容赦~。Githubにプロジェクトごと上げてあるので、そっちを見たほうが分かりやすいかもしれないです。
processBlockだけ抜粋するとこんな感じ。
list1 : PluginProcessor.cpp
void StereoPanAudioProcessor::processBlockWrapper(juce::AudioBuffer<sampleType>& buffer, juce::MidiBuffer& midiMessages)
{
    juce::ScopedNoDenormals noDenormals;
    auto totalNumInputChannels  = getTotalNumInputChannels();
    auto totalNumOutputChannels = getTotalNumOutputChannels();
    if (*masterBypass != false) return;
    
    for (int channel = 0; channel < totalNumInputChannels; ++channel)
    {
        auto* leftChannel = buffer.getWritePointer(0);
        auto* rightChannel = buffer.getWritePointer(1);
        //Caluculate the angle of rotation
        double Theta_r = -M_PI / 400 * valRotation;
        if (isRotationBypass > 0.5f){   //Bypass rotation
            Theta_r = 0.0;
        }
        for (int i = 0; i < buffer.getNumSamples(); ++i)
        {
            //Generate MS signals
            auto midInput = (leftChannel[i] + rightChannel[i]);
            auto sideInput = (leftChannel[i] - rightChannel[i]);
            //Processing rotation
            auto midRotation = midInput * cos(Theta_r) - sideInput * sin(Theta_r);
            auto sideRotation = midInput * sin(Theta_r) + sideInput * cos(Theta_r);
            //Revert to LR signals
            leftChannel[i] = (midRotation + sideRotation);
            rightChannel[i] = (midRotation - sideRotation);
        }
    }
}
ただし、valRotationはユーザー変数で、回転の大きさを-100~+100で設定できます。
実際に回転させてみるとこんな感じになります。
- 実装したPan(-50~+50で変化)
- 通常のPan(再掲)
Stereo Imagerが欲しい
いい感じですが、回転幅を-100~+100に拡大するとどうなるでしょうか。
上述の式を見てもらえればわかりますが、回転角が±π/4に近づくと、回転方向と逆のチャンネルには、元の信号のSide成分の逆相成分が現れるようになります。音源を聞いてみると、たしかに壁に張り付いたような、なんとも言えない違和感満載な音になってしまいました。
そこでStereo Imager的なものが欲しくなってきますね。回転角を大きくしたいときは、ステレオ幅を狭めてSide成分を減らしてから処理したほうが、より自然に聴こえる気がしてきます。
最も初歩的なStereo Imagerの理論的解説
先程同様とから、とを作ります。ステレオ幅をだけ広げた信号をおよびとすると、
と表せます。
ということは、入力信号に対してまずはこのStereo Imagerを掛けて、その出力を回転処理に突っ込んでやれば良さそうですね。
実装した
こうなります。processBlockだけ抜粋してます。
list2 : PluginProessor.cpp
void StereoPanAudioProcessor::processBlockWrapper(juce::AudioBuffer<sampleType>& buffer, juce::MidiBuffer& midiMessages)
{
    //中略
    for (int channel = 0; channel < totalNumInputChannels; ++channel){
        //中略
        float isWidthBypass = *widthBypass;
        float isRotationBypass = *rotationBypass;
        double Theta_w = M_PI / 200 * (valWidth - 50);
        if (isWidthBypass > 0.5f){  //Bypass width
            Theta_w = 0.0;
        }
        double Theta_r = -M_PI / 400 * valRotation;
        if (isRotationBypass > 0.5f){   //Bypass rotation
            Theta_r = 0.0;
        }
        for (int i = 0; i < buffer.getNumSamples(); ++i){
            //Generate MS signals
            auto midInput = (leftChannel[i] + rightChannel[i]);
            auto sideInput = (leftChannel[i] - rightChannel[i]);
            auto midWidth = midInput * sin(M_PI / 4 - Theta_w) * sqrt(2);
            auto sideWidth = sideInput * cos(M_PI / 4 - Theta_w) * sqrt(2);
            //Processing rotation
            auto midRotation = midWidth * cos(Theta_r) - sideWidth * sin(Theta_r);
            auto sideRotation = midWidth * sin(Theta_r) + sideWidth * cos(Theta_r);
            //Revert to LR signals
            leftChannel[i] = (midRotation + sideRotation);
            rightChannel[i] = (midRotation - sideRotation);
        }
    }
}
実際に使ってみるとこんな感じ。
- Widthを徐々に狭めたあと、回転した場合
- 特に何もせずに回転した場合(再掲)
かなり自然な回転に聴こえるようになったのではないでしょうか。
ただしこのステレオイメージャ、狭める分にはだいぶ自然ですが、広げようとするとだいぶ無理があります。
モダンなStereo Imagerでは、Haas効果を用いたアルゴリズムが多用されているような気がします。Mid成分をディレイさせた信号をSideに混ぜるだけらしいので、実装してみたいですね。
回転方向と逆のチャンネルにLPFを掛けたい
もう一捻り加えてみましょう。先程述べたとおり、違和感の原因となるSideの逆相成分は、回転方向と逆のチャンネルから出てきます。ステレオイメージャは、Panに入力される逆相成分を低減させるというのがコンセプトでしたが、今度は出力から逆相成分を取り除いてしまいましょう。要するに「回転方向と逆のチャンネルにだけ、LPFを掛ける」機能を実装するのです。
双二次フィルタによるIIR型LPF
ここは解説するのが大変面倒くさいので、他所のサイトにおまかせしましょう。

要約すると、LRC回路でLPFを構成し、その伝達関数を求め、これを双一次変換によって離散信号に適用できる形(双二次フィルタ)に変換すればいいということです。ただ、コレをいちいちやるのは面倒くさいので、双二次フィルタの係数をまとめてくれたページがあるわけですね。
実装した
こうなります。例のごとくprocessBlockだけ抜粋。
list3 : PluginProcessor.cpp
void StereoPanAudioProcessor::processBlockWrapper(juce::AudioBuffer<sampleType>& buffer, juce::MidiBuffer& midiMessages)
{
    //中略
    for (int channel = 0; channel < totalNumInputChannels; ++channel){
        //中略
        float valLPFFreq = *lpfFreq;
        double LPFBias = abs(valRotation) / 100;
        double _frequency = LPFBias * valLPFFreq + (1 - LPFBias) * 20000.0f;
        double _Q = 0.7;
        bool isLPFBypass = *lpfLink;
        //Get the sampling rate
        double _samplerate = getSampleRate();
        /**** Apply stereo width and rotation ****/
        for (int i = 0; i < buffer.getNumSamples(); ++i){
            //中略
            //Apply LPFLink
            if (isLPFBypass > 0.5f){
                if (Theta_r > 0.0){
                    LowPassR.reset();
                    LowPassR.coefficients = juce::dsp::IIR::Coefficients<double>::makeLowPass(_samplerate, _frequency, _Q);
                    rightChannel[i] = LowPassR.processSample(rightChannel[i]);
                }
                else if (Theta_r < 0.0){
                    LowPassL.reset();
                    LowPassL.coefficients = juce::dsp::IIR::Coefficients<double>::makeLowPass(_samplerate, _frequency, _Q);
                    leftChannel[i] = LowPassL.processSample(leftChannel[i]);
                }
            }
        }
    }
}
ユーザーがいじるパラメータはLPFLinkとLPFFreqです。LPFLinkは、LPFを掛けるか掛けないかを決めるパラメータです。LPFFreqは、回転角が最大のときのカットオフ周波数です。あとはRotationで回転させると、回転角の大きさに応じてカットオフ周波数が上下します。回転角が0に近づくとカットオフ周波数は20kHzに近づき、回転角が0のときは自動でBypassされます。
効果が分かりやすいように、LPFFreqを1Hzにして掛けてみました。
- LPFLinkをONにして回転した場合
- 何もしないで回転した場合(再掲)
コレはかなり自然に感じられるのではないでしょうか。おそらくですが、AOM社「Cyclic Panner」で導入されているLPFLinkも、これとほぼ同様の処理を行っていると考えられます。
最後に、Widthを狭めてLPFをガッツリ掛けた上で回転させた音源も聴いてみましょう。
おわりに
信号処理部分自体はかなり簡単だったのですが、C++がわからなすぎる+Juceのドキュメントがなさすぎるのダブルパンチで、実装には結構時間がかかってしまいました。あとUIが本当にわからない。PluginEditor.hには、ガチガチハードコーディングで各要素を配置していった苦闘の跡が見られるはずです(見てほしくないですが)。
今後の課題として、LPFLinkの前にはアップサンプリングをしたいなぁとか、ステレオイメージャをよりリッチなものに切り替えられるようにしたいなぁとか、そういう事を考えてます。誰かシュッと実装してくれないかなぁ。みなさんのコミットお待ちしてます。
 
		



