リングバッファってこうやって作るんですね
意外とシンプル
JUCEチュートリアル、「Create a string model with delay lines」を進めていきます。今回は、DSPの基本的なツールである、ディレイラインを実装して、基本的なエフェクトのひとつであるディレイを実装していきたいと思います。まずは、ディレイラインのリングバッファについて、重要な関数、pushとgetを中心にその動作などを見てみましたので、その内容を備忘録します。
公式チュートリアルはこちらになります。
こんな人の役に立つかも
・JUCE「Create a string model with delay lines」チュートリアルを進めている人
・Projucerテンプレートで「Create a string model with delay lines」チュートリアルの実装を行なっていきたい人
・JUCEでリングバッファのディレイを実装したい人
ディレイラインの実装
まずは、Delayクラスを実装します。DelayLineクラスは、リングバッファの仕組みを実装したクラスになります。privateなメンバのstd::vector配列の操作を主に行います。vectorクラスのインデックスを先頭と末尾で繰り返すようにすることで、リングを作るようなイメージです。
push関数
PluginProcessor.hのDelayLineクラスのPush関数を次のように変更します。以下のプログラムには、push関数以外にも、size()関数、privateなメンバも含めて表示しています。これは、push関数内部に利用されている変数、関数が一目でわかるようにしているだけです。変更する部分はpush関数の中のみです。
template <typename Type>
class DelayLine
{
//...略...
size_t size() const noexcept
{
return rawData.size();
}
//...略...
void push (Type valueToAdd) noexcept
{
//ignoreUnused (valueToAdd);
//[1]↑を以下のように変更しました。以下の2行が追加したプログラムです。
rawData[leastRecentIndex] = valueToAdd;
leastRecentIndex = leastRecentIndex == 0 ? size() - 1 : leastRecentIndex - 1;
}
//...略...
private:
std::vector<Type> rawData;
size_t leastRecentIndex = 0;
};
Push関数は、rawData配列(std::vrctor)にデータを格納する処理です。rawData配列のインデックスは、「leastRecentIndex」変数になっています。
leastRecentIndexは、push関数が呼び出されたときのleastRecentIndexの値によって、三項演算子で自身を上書きしてインデックス番号を更新します。leastRecentIndexが0のとき、size関数から返されるrawData配列のサイズ-1の値として自身(leastRecentIndex)を更新、0ではないときは、自分自身から1を引いた数を返します。push関数は、呼び出される毎にデータを格納して、インデックス番号を1づつ引き、0になったらまた配列サイズのインデックスに戻る、という動作がみてとれます。
get関数
次に、get関数です。
Type get (size_t delayInSamples) const noexcept
{
jassert (delayInSamples >= 0 && delayInSamples < size());
//juce::ignoreUnused (delayInSamples);
//return Type (0);
//[1]↑を以下のように変更しました。
return rawData[(leastRecentIndex + 1 + delayInSamples) % size()];
}
get関数は、呼び出されると、rawData配列の値を返します。引数にdelayInSamplesとして取得する配列データのインデックスを受け取ります。まずは、jassertで配列サイズを外れた数値でないことを確認します。
そして、[1]の書き換えたプログラムで、配列データをreturnします。インデックスの「(leastRecentIndex + 1 + delayInSamples) % size()」は、rawData配列のサイズで剰余演算を行っています。
delayInSamplesは、遅延させるサンプル数が入ってきます。delayInSamplesがバッファサイズを越えてしまうことが内容に注意する必要があるとのことです。
push関数とget関数の挙動
チュートリアルの説明だけでは、具体的にどのようにリングバッファになっているのか、現段階では分かりにくいので、Delay関数内でどのように利用されているかを少し確認してみました。主に、上で実装したpushとgetが中心となってリングバッファの仕組みが動作していることがわかりました。
大まかに流れを追うと、process関数の中で、get→pushの順番で呼び出されています。
例として、最大の遅延サイズをサンプルレート44100(1秒)、遅延を100サンプルと設定してみます。
rawData配列は44100個(0番~44099番)が確保されます。
leastRecentIndexが0で始まります。
get関数が呼び出されます。この時、「(leastRecentIndex + 1 + delayInSamples) % size()」のインデックス番号で、「(0 + 1 + 100) % 44100」= 「101番」の配列要素が取得できます。
次にpush関数が呼び出されます。この時、leastRecentIndexは0なので、「size() – 1」の計算である「44100 – 1 = 44099」にleastRecentIndexが上書きされます。( leastRecentIndex=0のときにデータを格納します。)
次の音声データに進み(Delayクラス内、process関数の音声ブロックの処理ループ内)、再度get関数が呼び出されます。この時、 「(leastRecentIndex + 1 + delayInSamples) % size()」 は「(44099 + 1 + 100) % 44100」=「100」番目のrawDataの配列要素が取得できます。
そして、push関数が呼び出されます。この時、 leastRecentIndexは「 leastRecentIndex -1」となり、値としては「44098」となります。
これが繰り返されて、leastRecentIndexが0になると再度44099に戻り、バッファデータが連続的に続いているように見えます。
図式化してみると次のようになりました。
rawDataに対して、44100個の領域、黄色がgetで取得するデータ、オレンジがpushで設定するデータです。pushでオレンジから緑の位置にインデックスを移動させます。getとpushでのインデックスが平行に動いていくので、pushでデータを格納して100サンプル後にgetが参照、するような動作になります。いたちごっこみたいです。
先ほどget関数での遅延時間が最大遅延バッファより大きい点に注意ということについて考えてみました。
遅延を44100(1秒)を設定した場合、get関数の計算で(0+1+44100) % 44100 = 1や、(44099+1+44100) % 44100 = 0となり、参照するバッファが足りなくなってしまう現象が見られます。
遅延が44099サンプルの場合、(0+1+44099) % 44100 = 0、 (44099+1+44099) % 44100 = 44099なので、pushしてデータを格納する配列インデックス番号と、遅延を参照するインデックス番号が同じ位置になり、ディレイになりません。
遅延が44098サンプルの場合、(0+1+44098) % 44100 = 44099となり、ギリギリ配列の最後のインデックスに収まり、getとpushが44099サンプル分平行してインデックスが移動することになります。遅延時間がバッファを越えるという表現の後ろには、「遅延サンプル数<配列サイズー1」ということができそうです。
その他の関数の確認
set関数です。現段階では、とりあえずチュートリアルに沿って実装しました。
void set (size_t delayInSamples, Type newValue) noexcept
{
jassert (delayInSamples >= 0 && delayInSamples < size());
//ignoreUnused (delayInSamples, newValue);
//[1]↑を以下のように変更しました。
rawData[(leastRecentIndex + 1 + delayInSamples) % size()] = newValue;
}
rawData配列に値を上書きするような関数です。
他にもDelayLineクラスは次の関数をもっていますが、次のチュートリアル項目である、ディレイの実装では、次に実装するDelayクラスからpush関数、get関数、resise関数、clear関数を利用しています。
resize関数とclear関数はすでにデモのプログラムをコピペした段階で実装されています。
void clear() noexcept
{
std::fill (rawData.begin(), rawData.end(), Type (0));
}
resize関数は、単純に配列を最初から最後まで0で埋めるという動作を行います。std:fill関数で、vector配列であるrawDataをすべて0で埋めます。
次に、resize関数です。
void resize (size_t newValue)
{
rawData.resize (newValue);
leastRecentIndex = 0;
}
std::vectorクラスのresize関数を使用して、新しい配列のサイズに設定しなおします。ディレイラインの配列のサイズ自体を変更する際に利用します。そして、leastRecentIndex変数を0にして、配列のインデックスを初期化しておきます。
今回はディレイラインの理解を深めるためにかなり時間を使ってしまいました^^;
次回はディレイラインを使ったディレイエフェクトを実装していきたいと思います。