feature image

2021年12月8日 | ブログ記事

C++ with JUCEでステレオパンを作ってみた【AdC2021 26日目】

この記事はtraP Advent Calendar 2021 26日目の記事です。

お前は誰だ

19Bのリキッドです。普段はサウンド班や、個人サークル「華力発電所」などで音楽を作るなどしています。

プラグインを作ろう

今日お話するのは、PCベースの楽曲制作においては欠かすことのできない、VSTプラグインについてです。
VSTプラグインは、独・Steinberg社が開発したVST SDKを用いて、C++によって作ることができます。VST SDKを入手することで、誰でもプラグインを開発することができます。
とは言うものの、素の状態でもだいぶ厄介なC++の扱いに加え、オーディオ信号を処理するための知識や、VST SDKを扱うためにドキュメントやら何やらを読みまくるのは大変な作業です。この大変な作業をだいぶ楽にしてくれるライブラリ、それがJUCEです。

JUCE | JUCE
Deliver music applications on all main platforms, with high performances and professional tools

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

【一緒に始めよう】VSTプラグインをつくる【AdC2020 21日目】
この記事はtraPアドベントカレンダー2020 21日目の記事です。 ブラックフライデーが終わりましたがみなさんは今年どれくらい散財しましたか?僕は去年のこの時期にだいぶ色々買い揃えてしまったので、iZotope Music Production Suite 4とAAS Strum GS-2とBest Service Era 2 Vocal CodexとMelda Production MAutoAlignとHeaviocity AscendとOutput PortalとGlitchmachines Convex/Fracture XT/Palindromeだけで済みました向こう半年はモヤシし…

Stereo Panってなんだ

通常DAWなどの各トラックに備え付けのPanは、左右チャンネルの音量を調整することで、音源の定位を操作しています。モノラル音声に対してはこの操作で何の問題もないのですが、ステレオ音声に関しては話が変わってきます。ステレオ音声の左右の音量バランスを変えてしまうと、ステレオイメージが崩れてしまいます。例として以下のような音源を用意しました。

これでは、ドラムの音声が右に回転した、とは言えません。この問題を解決するのが、Stereo Panです。

類似品はいねが~

あります。有名所は、Waves 「S1 Imager」、Boz Digital Labs 「Pan Knob」、A.O.M. 「Cyclic Panner」などでしょうか。S1は持っているのですが、ちょいと時代遅れ、Cyclic Pannerは大人気製品のようですが、如何せん高い。Bozはよく知らないです。

そういうわけで、勉強がてら自分で作ってしまおうと思ったわけです。

作った

こんな感じに出来ました。

論より証拠、音を聞いてもらいましょう。

かなりいい感じじゃないですか?ではこのLPannerがどのように作られたかを見ていきましょう。

Stereo Panの理論的解説

L Channelの信号を、R Channelの信号をとします。このとき、音源のMid成分(またはMono成分)およびSide成分(またはStereo成分)は、それぞれ

と表せます。コイツらの成すステレオ音場を左にだけ回転させることを考えると、回転後のMS信号およびは、

となります。最後にこのMS信号をLR信号に戻してやれば、出力及びは、

となります。これをC++とJuceで実装してやれば良いわけです。

実装した

ここからは実際のソースを見ながら解説します。もともと人に見せる予定はなかったので、クッソ汚いですがご容赦~。Githubにプロジェクトごと上げてあるので、そっちを見たほうが分かりやすいかもしれないです。

GitHub - liquid1224/StereoPan
Contribute to liquid1224/StereoPan development by creating an account on 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で設定できます。

実際に回転させてみるとこんな感じになります。

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);
        }
    }
}

実際に使ってみるとこんな感じ。

かなり自然な回転に聴こえるようになったのではないでしょうか。
ただしこのステレオイメージャ、狭める分にはだいぶ自然ですが、広げようとするとだいぶ無理があります。
モダンなStereo Imagerでは、Haas効果を用いたアルゴリズムが多用されているような気がします。Mid成分をディレイさせた信号をSideに混ぜるだけらしいので、実装してみたいですね。

回転方向と逆のチャンネルにLPFを掛けたい

もう一捻り加えてみましょう。先程述べたとおり、違和感の原因となるSideの逆相成分は、回転方向と逆のチャンネルから出てきます。ステレオイメージャは、Panに入力される逆相成分を低減させるというのがコンセプトでしたが、今度は出力から逆相成分を取り除いてしまいましょう。要するに「回転方向と逆のチャンネルにだけ、LPFを掛ける」機能を実装するのです。

双二次フィルタによるIIR型LPF

ここは解説するのが大変面倒くさいので、他所のサイトにおまかせしましょう。

双2次フィルタ
概要分母・分子がともに2次のIIRフィルタを双2次フィルタと呼びます。双2次フィルタは、以下のような理由から、非常によく利用されています。 単純。 設計手法が確立している。 直列に繋…

要約すると、LRC回路でLPFを構成し、その伝達関数を求め、これを双一次変換によって離散信号に適用できる形(双二次フィルタ)に変換すればいいということです。ただ、コレをいちいちやるのは面倒くさいので、双二次フィルタの係数をまとめてくれたページがあるわけですね。

Audio EQ Cookbook

実装した

こうなります。例のごとく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にして掛けてみました。

コレはかなり自然に感じられるのではないでしょうか。おそらくですが、AOM社「Cyclic Panner」で導入されているLPFLinkも、これとほぼ同様の処理を行っていると考えられます。

最後に、Widthを狭めてLPFをガッツリ掛けた上で回転させた音源も聴いてみましょう。

おわりに

信号処理部分自体はかなり簡単だったのですが、C++がわからなすぎる+Juceのドキュメントがなさすぎるのダブルパンチで、実装には結構時間がかかってしまいました。あとUIが本当にわからない。PluginEditor.hには、ガチガチハードコーディングで各要素を配置していった苦闘の跡が見られるはずです(見てほしくないですが)。

今後の課題として、LPFLinkの前にはアップサンプリングをしたいなぁとか、ステレオイメージャをよりリッチなものに切り替えられるようにしたいなぁとか、そういう事を考えてます。誰かシュッと実装してくれないかなぁ。みなさんのコミットお待ちしてます。

liquid1224 icon
この記事を書いた人
liquid1224

traPサウンド藩の浪士です。はやく老師になりたい。

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2023年4月25日
【驚愕】作曲4年目だった男が大学3年間ゲームサウンドに関わった末路...【ゲームサウンドのお仕事について】
tenya icon tenya
ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】 feature image
2018年11月3日
ERC20トークンを用いた宝探しゲーム(真)の提案【アドベントカレンダー2018 10日目】
Azon icon Azon
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2023年4月27日
Vulkanのデバイスドライバを自作してみた
kegra icon kegra
2024年3月17日
⑨でもわかる8bitアレンジ講習会
vPhos icon vPhos
2020年3月16日
【 #LogicProX 】#DrumKitDesigner から #UltraBeat に乗り換えよう 第2話
SolunaEureka icon SolunaEureka
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記