【JUCEチュートリアル】Create a string model with delay lines、ウェーブガイドシンセの処理内容の考察

パンダさん

前回は音を出すために実装したので、あまり処理内容を考えていませんでした。

コパンダ

できるだけ理解できたことをメモしておくべきだね~

JUCEチュートリアル、「Create a string model with delay lines」のWaveguideStringクラスを実装しました。今回は、できるだけ、そのプログラムの中で何をしているのかを紐解いていけたらなと思っています。

公式チュートリアルはこちらになります。

目次

こんな人の役に立つかも

・JUCE「Create a string model with delay lines」チュートリアルを進めている人

・Projucerテンプレートで「Create a string model with delay lines」チュートリアルの実装を行なっていきたい人

・waveguide synth方式の仕組みを調べている人

ウェーブガイドシンセについて調査

音響波の伝播を「遅延」を用いてシミュレートするような理論です。この時、進行波(traveling waves)というものをシミュレートするようです。

https://ccrma.stanford.edu/~jos/pasp/Traveling_Waves.html

「進行波」は、形状に関係なく、一方向に伝播するような波で、信号の遅延で単純にシミュレートすることができるようです。チュートリアルの方向性の要点としては、今まで実装してきた「ディレイライン」の仕組みを使って、弦の進行波と呼ばれるものをシミュレートしているというような雰囲気を感じ取れました。

チュートリアルの「WaveguideString」クラスのメンバには、「fowardDelayLine」と「backwardDelayLine」という2つのディレイラインが利用されます。

海外のwikipediaの「waveguide synth」の項目の下部が、いろいろな論文にリンクされていてここから深めていくこともできそうです。

https://en.wikipedia.org/wiki/Digital_waveguide_synthesis

いろいろとみてみたのですが、何となくディレイバッファを2つ利用して、みたいな部分くらいの雰囲気がつかみ取れたくらいでした^^;

実装を見てみて、また論文を読んだら何か気づきがあるかもしれません。ということで、実装内容を見ていきたいと思います。

チュートリアルで実装している処理の概要

パラメータについて

まず、プログラム内で利用されるメンバ変数を把握しておくために、WaveguideStringクラスのprivateなメンバ変数を抜粋します。

//privateなメンバ変数の抜粋 
    DelayLine<Type> forwardDelayLine;
    DelayLine<Type> backwardDelayLine;
    juce::dsp::IIR::Filter<Type> filter;

    juce::HeapBlock<char> heapBlock;
    juce::dsp::AudioBlock<float> tempBlock;

    size_t forwardPickupIndex{ 0 };
    size_t backwardPickupIndex{ 0 };
    size_t forwardTriggerIndex{ 0 };
    Type decayCoef;

    Type sampleRateHz{ Type(1e3) };
    Type freqHz{ Type(1) };
    Type pickupPos{ Type(0) };
    Type triggerPos{ Type(0) };
    Type decayTime{ Type(0) };

テンプレートクラスなので、すべてTypeという型名になります。チュートリアルプラグインでは、Voiceクラスで<float>と指定しますので、ここはすべてfloat型の数値になります。{Type(1)}などの表記は初期化の表記で、今回の場合、float(1)という処理になります。関数型での初期化の記載を行っています。

大まかな処理の流れ

WaveguideStringクラスに関する、処理の概要を整理していきたいと思います。処理の大きな流れとしては、次の3つの流れがあります。

1)初期化時の流れ

2)トリガー時の流れ

3)音声処理の流れ

初期化時の流れは、プラグイン起動時に実行される処理です。トリガー時の流れは、MIDIノートオン信号が発生したタイミングで開始される処理です。そして、音声処理は、常に入力される音声バッファブロックに対する音声出力への処理です。

初期化時の流れ

初期化のタイミングは、プラグインの起動時と、DAW側でのオーディオ設定でサンプリングレートの変更時があげられます。

プラグイン起動時には、コンストラクタ+prepare関数が呼び出されます。そのため、「setTriggerPosition」「setPickupPosition」「setDecayTime」が実行され、privateなメンバ「pickupPos」「triggerPos」「decayTime」の値がセットされるのは、起動時のタイミングになります。

//コンストラクタ
    WaveguideString()
    {
        setTriggerPosition(Type(0.2));
        setPickupPosition(Type(0.8));
        setDecayTime(Type(0.5));
    }

//利用関数の抜粋
    void setPickupPosition(Type newValue)
    {
        jassert(newValue >= Type(0) && newValue <= Type(1));
        pickupPos = newValue;
        updateParameters();
    }

    void setTriggerPosition(Type newValue)
    {
        jassert(newValue >= Type(0) && newValue <= Type(1));
        triggerPos = newValue;
        updateParameters();
    }

    void setDecayTime(Type newValue) noexcept
    {
        jassert(newValue >= Type(0) && newValue <= Type(1));
        decayTime = newValue;
        updateParameters();
    }

それぞれの関数内でupdateParametesが何回も呼び出されることになります。

サンプリングレートの変更時には、prepare関数のみが呼び出されます。prepare関数内は、周波数の設定とフィルター係数の設定を行います。もちろん、updatePrameters関数も呼び出されます。

    void prepare(const juce::dsp::ProcessSpec& spec)
    {
        sampleRateHz = (Type)spec.sampleRate;
        tempBlock = juce::dsp::AudioBlock<float>(heapBlock, spec.numChannels, spec.maximumBlockSize);
        filter.prepare(spec);
        updateParameters();
    }

また、prepare関数内では、tempBlockという一時的に音声バッファを保持するバッファも準備しています。

ここで、何回も呼び出されているupdateParameters関数は、アルゴリズムのためのパラメータを計算する重要な関数になってきます。

    void updateParameters()
    {
        //[1]2つのディレイラインのバッファサイズを決定します。
        auto length = (size_t)juce::roundToInt(sampleRateHz / freqHz);
        forwardDelayLine.resize(length);
        backwardDelayLine.resize(length);

        //[2]ピックアップインデックス変数の値を設定します。
        forwardPickupIndex = (size_t)juce::roundToInt(juce::jmap(pickupPos, Type(0), Type(length / 2 - 1)));
        backwardPickupIndex = length - 1 - forwardPickupIndex;

        //[3]トリガー変数の値を設定します。
        forwardTriggerIndex = (size_t)juce::roundToInt(juce::jmap(triggerPos, Type(0), Type(length / 2 - 1)));

        //[4]フィルターモジュールを1次のハイパスフィルターに(係数)設定します。
        filter.coefficients = juce::dsp::IIR::Coefficients<Type>::makeFirstOrderLowPass(sampleRateHz, 4 * freqHz);

        //[5]ディケイ(音の減衰)の値を計算します。
        decayCoef = juce::jmap(decayTime, std::pow(Type(0.999), Type(length)), std::pow(Type(0.99999), Type(length)));

        reset();
    }

[1]では、ディレイラインのバッファサイズを決定します。freqHzは、MIDIノートオン時にノートナンバーの周波数が入るのですが、初期化時、アプリ起動時は、1という定数が入ります。そのため、サンプリングレートが44100の場合、lengthが44100となり、fowawrdDelayLineとbackwordDelayLineのバッファサイズ(配列の要素数)は44100個ということになります。

[2]では、「forwardPickupIndex」「backwardPickupIndex」という音声処理で利用する変数を計算します。PickUpPosの0.8という値を「0」~「lengthの半分」の値にスケーリングを変更します。今回のpickUpPos、0.8という値の時点で、「0」~「44100÷2-1 = 22049」を当てはめると、「22049×0.8」でRoundToInt関数で丸めて、17639という値になりました。

バッファ全体からみると、4割くらいの場所の配列のインデックス数を取得することになります。

backwardPickupIndex は、この4割地点のポジションをそのままlengthから引いた値なので、左右対称に、配列の後ろ方向から4割の場所のインデックス数値となります。

[3]のforwardTriggerIndexも先ほどの計算と同じで、setTriggerPosで設定された初期値0.2を「0~22049」の値の範囲でマッピングしています。0.2の値は、4409となりますので、バッファ全体の1割のインデックスの数を格納することになります。

[4]では、フィルターモジュールに発音周波数の4倍の周波数をカット周波数としたローパスフィルターを設定しています。ローパスなので、発音周波数の4倍の高域成分をカットするようなフィルターです。

[5]では、音の減衰の係数を算出します。powはべき乗の関数なので、0.999の44100乗~0.99999の44100乗までの範囲で0.5をマッピングするのですが、今回は初期値の1で、lengthが44100という発音時には、非現実的な数値になっています。実際に発音時では、1Hzという発音は非可聴領域なので、現実的に低くて50Hz程度かと思います。50Hzの場合、lengthは「44100÷50」で「882」となります。この場合、「decayCoef」変数は、「0.413…~0.991…」の範囲にマッピングされます。関数電卓で計算したところ、0.5の場合、0.702という係数になりました。 decayCoef変数は、後の発音の際に、音の減衰率として織り込まれるような変数ですので、この辺りの計算は、論文的な根拠がある、のだと思っています^^;

発音のトリガーに関する流れ

MIDI入力でVoiceクラスのnoteStarted関数をトリガーとしてWaveguideStringクラスのtrigger関数が実行されます。

MIDI入力ノートナンバーから、発音周波数、キーの入力velocityを取得して、次の関数を呼び出します。

//Voiceクラスの抜粋です。
    void noteStarted() override//MIDIノートオン時に実行される関数です。
    {
        auto velocity = getCurrentlyPlayingNote().noteOnVelocity.asUnsignedFloat();
        auto freqHz = (float)getCurrentlyPlayingNote().getFrequencyInHertz();

        processorChain.get<oscIndex>().setFrequency(freqHz, true);

        auto& stringModel = processorChain.get<stringIndex>();
        stringModel.setFrequency(freqHz);//[1]周波数を行います。
        stringModel.trigger(velocity);//[2]ここでtrigger関数が呼び出されます。
    }

noteStarted関数では、[1]「setFrequency関数」と[2]「trigger関数」が呼び出されます。

setFrequency関数内で、周波数「freqHz」変数が設定されます。これは、引いた鍵盤のノートナンバーを周波数に変更したものになります。そして、「updateParameters関数」が呼び出されます。

    void setFrequency(Type newValueHz)
    {
        freqHz = newValueHz;
        updateParameters();
    }

[2]のtrigger関数では、2つのディレイラインに音声データを生成してバッファを埋めるような処理を行っています。

    void trigger(Type velocity) noexcept
    {
        jassert(velocity >= Type(0) && velocity <= Type(1));

        //[1]バッファへデータを生成するループ①です。
        for (size_t i = 0; i <= forwardTriggerIndex; ++i)
        {
            //[1-1]ゲイン値の作成
            auto value = juce::jmap(Type(i), Type(0), Type(forwardTriggerIndex), Type(0), velocity / 2);
            //[1-2]ディレイラインのバッファを埋めます。
            forwardDelayLine.set(i, value);
            backwardDelayLine.set(getDelayLineLength() - 1 - i, value);
        }

        //[2]バッファへデータを生成するループ②です。
        for (size_t i = forwardTriggerIndex; i < getDelayLineLength(); ++i)
        {
            auto value = juce::jmap(Type(i), Type(forwardTriggerIndex), Type(getDelayLineLength() - 1), velocity / 2, Type(0));
            forwardDelayLine.set(i, value);
            backwardDelayLine.set(getDelayLineLength() - 1 - i, value);
        }
    }

trigger関数では、2つのディレイラインに、発音する音声を生成するような処理を行います。引数として取得したvelocityには、ノートオン時の強さ(ベロシティ値)が入ってきます。

[1]のループ①では、0~forwardTriggerIndex番目(初期化時にバッファの1割程度の場所を指し示す)までをループしています。[1-1]のように、value変数にゲイン値を作成します。

[1-1]のvalue値は、jmap関数で「0~ forwardTriggerIndex 」の範囲を「0~「velocity÷2」」の値の範囲でマッピングしたものになります。そのため、iが forwardTriggerIndex番目ループの最後がvelocity÷2の値となります。

fowardDelayLineとbackwordDelayLineはインデックスを逆にしていて、左右対称にデータが格納されていきます。

[1]と[2]のループによって、次のように2つのディレイバッファにゲインデータが格納されることになります。

音声処理

設定されたパラメータに基づいて出力音声を出力します。

processSample関数は、2つのディレイラインに生成された初期波形データの値を取り出して、データを更新、そして、ピックアップ位置で出力音声データの合成を行うような処理を行っています。

    Type processSample() noexcept
    {
        //[1]バッファからデータを取り出します。
        auto forwardOut = forwardDelayLine.back();
        auto backwardOut = backwardDelayLine.back();

        //[2]バッファを更新してインデックスを進めます。
        forwardDelayLine.push(-backwardOut);
        backwardDelayLine.push(-decayCoef * filter.processSample(forwardOut));

        //[3]2つのディレイラインのPickupIndexの位置でデータを合成して返します。
        return forwardDelayLine.get(forwardPickupIndex) + backwardDelayLine.get(backwardPickupIndex);
    }

[1]では、ディレイラインのバッファの現在のインデックス位置から取り出します。初期状態では、trigger関数で生成されたデータを取り出すことになります。

[2]で、ディレイラインのpush関数は現在のインデックスにデータを入れて、インデックスをひとつ進めるので、ディレイライン内のバッファのデータを更新することになります。

ディレイラインはリングバッファになっているので、永遠に続くバッファのように考えると、次ループしてここにたどり着いたときの音を格納していることになります。また、ディレイラインのバッファの長さは発音周期の周波数(MIDIノートオンの周波数)となっているので、fowardDelayLineには反対位相の値を入れることで、振幅の再現ができるのかなと考えています。

backwordDelayLineには、高域をフィルタリングしたfowardDelayLineの値に、減衰係数を掛けた値を格納しています。

[3]では、コンストラクタで設定したそれぞれのディレイラインのPickupIndexのバッファ(今回はディレイラインバッファ全体の4割の場所)のデータを取得して、2つのディレイラインの値を合成して返します。

そして、「process関数」の次の[1]の部分で、バッファブロックのすべてのサンプルに対して先ほどの処理が行われます。process関数は、音声処理バッファブロック毎に繰り返し行われる処理なので、DAW側の設定で音声バッファのサイズを512サンプルと設定した場合、1ブロック512サンプル毎にprocess関数が実行されることになります。そのため、1回のprocess関数の実行で、[1]のループは512回処理されることになります。

    template <typename ProcessContext>
    void process(const ProcessContext& context) noexcept
    {
        auto&& outBlock = context.getOutputBlock();
        auto numSamples = outBlock.getNumSamples();
        auto* dst = tempBlock.getChannelPointer(0);

        //[1]バッファブロック内のすべてのサンプルデータ
        for (size_t i = 0; i < numSamples; ++i)
            dst[i] = processSample();

        for (size_t ch = 1; ch < tempBlock.getNumChannels(); ++ch)//[2]
            juce::FloatVectorOperations::copy(tempBlock.getChannelPointer(ch),
                tempBlock.getChannelPointer(0),
                (int)numSamples);

        outBlock.copyFrom(context.getInputBlock()).add(tempBlock.getSubBlock(0, outBlock.getNumSamples()));
    }

処理として、何となく全体をつかめてきた感はあるのですが、まだいまいち、2つのディレイラインのバッファの進行方向、音波の合成の部分で、完全に腑に落ちていないところがあり、理解するまでには、もう少し深く考えていく必要がありそうです。

よかったらシェアしてね!
目次
閉じる