LFOができると、シンセサイザーとしてすごく幅が広がりそうです。
応用すれば、いろいろなエフェクトが作れるようになりそうだね~
JUCEチュートリアル、Intorduction to DSPの「Modulating the signal with an LFO」を進めていきます。今回は、FLOでラダーフィルタのカットオフ周波数を変調させるようなエフェクティブな内容です。
この記事は前回までの記事の続きとなっています。
オシレータの作成、CustomOscillatorクラスの実装
オシレータにいろいろな波形を設定、ルックアップテーブルによる波形の生成
こんな人の役に立つかも
・JUCEチュートリアル、Introduction to DSPをやっている人
・JUCEでシンセサイザーが作成したい人
・JUCEでLFOを実装したい人
LFOについて
LFO(LowFrequencyOscillator)は、可聴領域より低い(10Hzなど)の低周波を発するためのオシレータです。シンセサイザーでは、パラメータを揺らしたりして、モジュレーションをかけたりするために利用します。
例えば、LFOを音量にかけると、音量が周期的に大小することで、トレモロのような効果を得られたり、フィルターのカットオフ周波数にかけると、周期的にフィルターの開閉が行われてシュワシュワしたサウンドが得られたり、とエフェクティブな用途で利用することができます。
実装と挙動の確認
lfo用オシレータの準備
LFOは、オシレータなので、dsp::Oscillatorクラスを使います。普通の発音のオシレータと同じです。
ただ、processorChainに追加はしません。(processorChainに追加すると、直列に接続されて、単純に低周波が発音されるだけになります。)
並列した他の発振機構として、効果を及ぼす対象を選択できるように実装していく必要があります。そのため、voiceクラスに直接オシレータクラスのオブジェクトを作成してrenderNextBlock内で出力音に影響を与えるような方法をとるようです。
Voiceクラスにprivateなメンバとして、新たなdsp::Oscillatorクラスのオブジェクト、「lfo」の作成等を行います。
private:
//...略...
static constexpr size_t lfoUpdateRate = 100;
//[1]追加しました。
size_t lfoUpdateCounter = lfoUpdateRate;
juce::dsp::Oscillator<float> lfo;
};
次に、コンストラクタです。以前実装した発音のオシレータと同様に、ラムダ式で発音波形を作成して、発振周波数を指定します。滑らかな変化をさせるために、サイン波を利用します。周波数は3Hzです。耳には聞こえません。
Voice()
{
//...略...
//追加しました。
lfo.initialise([](float x) { return std::sin(x); }, 128);
lfo.setFrequency(3.0f);
}
prepare関数では、与えるサンプルレートを100分の1したものに設定します。次の音声処理でLFOは100サンプルにつき1回処理を行うので、設定するサンプルレートを100分の1にしなければいけません。specのサンプルレート値を100分の1とするため、「sampleRate/lfoUpdateRate」を行います。
void prepare(const juce::dsp::ProcessSpec& spec)
{
tempBlock = juce::dsp::AudioBlock<float>(heapBlock, spec.numChannels, spec.maximumBlockSize);
processorChain.prepare(spec);
//追加しました。
lfo.prepare({ spec.sampleRate / lfoUpdateRate, spec.maximumBlockSize, spec.numChannels });
}
LFOは、オシレータクラスのオブジェクトなので、サンプルレートに対する3Hzを生成します。音声バッファ100サンプルに対し1回利用したときに3Hzとするためには、サンプルレートがシステムのサンプリングレートに対して100分の1の解像度のサイン波を生成するようにするため、LFOに与えるサンプルレートをこのようにしています。
100回に1回である理由は、チュートリアルの作者の裁量によるものでしょうか。
renderNextBlock関数
renderNextBlock関数で、100サンプルにつき1回、LFOでラダーフィルタのカットオフ周波数の値を変化するような仕組みを実装していきます。
[1]は以前の処理ですが、[2]のように変更しています。
void renderNextBlock(juce::AudioBuffer<float>& outputBuffer, int startSample, int numSamples) override
{
//[1]次のプログラムを削除しました。
/*auto block = tempBlock.getSubBlock(0, (size_t)numSamples);
block.clear();
juce::dsp::ProcessContextReplacing<float> context(block);
processorChain.process(context);*/
//[2]代わりに、次のようにoutputで書き直します。
auto output = tempBlock.getSubBlock(0, (size_t)numSamples);
output.clear();
//[3]今回のLFOの仕組みになります。
for (size_t pos = 0; pos < (size_t)numSamples;)
{
auto max = juce::jmin((size_t)numSamples - pos, lfoUpdateCounter);
auto block = output.getSubBlock(pos, max);
juce::dsp::ProcessContextReplacing<float> context(block);
processorChain.process(context);
pos += max;
lfoUpdateCounter -= max;
//[3-1]LFOの処理です。
if (lfoUpdateCounter == 0)
{
lfoUpdateCounter = lfoUpdateRate;
auto lfoOut = lfo.processSample(0.0f);
auto curoffFreqHz = juce::jmap(lfoOut, -1.0f, 1.0f, 100.0f, 2000.0f);
processorChain.get<filterIndex>().setCutoffFrequencyHz(curoffFreqHz);
}
}
//[4]ここは変更ありません。
juce::dsp::AudioBlock<float>(outputBuffer)
.getSubBlock((size_t)startSample, (size_t)numSamples)
.add(tempBlock);
}
[3]の処理からが、今回のメインとなる処理です。for文とpos、max、lfoUpdateCounterなどの変数で100サンプルに1回というタイミングを生成します。
100サンプルに1回のタイミングで、[3-1]の処理内容を行い、FLOの値からラダーフィルタのカットオフ周波数を変化させます。LFOが出力するのは、ゲイン値なので、0~1の値です。今回は、100Hzから2000Hzの値の範囲でカットオフ周波数を変化させたいので、jmap関数で0~1の値を100~2000の値にスケーリングしなおします。そして、cutoffFreqHzとして得られた周波数値をラダーフィルターのカットオフ周波数に設定します。
100サンプルに1回のタイミングを生成する仕組みを図にしてみました。音声バッファサイズを例として256としています。
バッファサイズの100サンプル毎にprocessを行います。256サンプルなので、最後56サンプルの余剰が出ます。このとき、最後のlfoUpdateCounter変数は44という値になるので、100サンプルに1回のタイミングの条件であるlfoUpdateCounter==0を満たしません。processorChainのprocessのみ音声処理が反映されます。このままposがnumSamplesを上回り、for文が終了します。
次の音声処理ブロックが渡され、renderNextBlock関数が実行されると、lfoUpdateCounterは44の状態です。そのため、blockは最初の44サンプル分わたされ、先のループの最後の余剰blockと合わせて100となります。そのため、今回はFLOの処理が行われます。そして、次のforループへと進んでいきます。
このような感じで、renderNextBlock関数に渡されてきた音声サンプルの数に対してきっちり100サンプル毎のタイミングを得ることができます。
以外に複雑な仕組み
全体として、大きな3Hzのサイン波に乗っているように小さな波形が揺れるのが確認できました。