チュートリアル:DSP入門
デジタル信号処理とオーディオバッファ操作の領域を発見しましょう。JUCE DSPモジュールの基本を学び、そのクラスを独自のオーディオアプリケーションやプラグインに組み込む方法を習得します。
レベル: 上級
プラットフォーム: Windows, macOS, Linux
プラグイン形式: VST, AU, Standalone
クラス: dsp::ProcessorChain, dsp::Gain, dsp::Oscillator, dsp::LadderFilter, dsp::Reverb
このプロジェクトにはC++14機能をサポートするコンパイラが必要です。最新バージョンのXcodeとVisual Studioにはこのサポートが含まれています。
はじめに
このチュートリアルを読む前に、シンセシスの基本を理解し、MPEについて紹介されていることを確認してください。MPEについてもっと知りたい場合は、このチュートリアルをご覧ください:チュートリアル:マルチポリフォニックシンセサイザーの構築。
このチュートリアルのデモプロジェクトをこちらからダウンロードしてください:PIP | ZIP。プロジェクトを解凍し、最初のヘッダファイルをProjucerで開いてください。
この手順でサポートが必要な場合は、チュートリアル:Projucer Part 1: Projucerを始めようを参照してください。
デモプロジェクト
このプロジェクトはプラグインとして構想されていますが、IDEで適切なデプロイメントターゲットを選択することでスタンドアロンアプリケーションとして実行できます。Xcodeでは、以下のスクリーンショットに示すように、メインウィンドウの左上隅でターゲットを変更できます:

デモプロジェクトは、プラグインの上半分に画面上のMIDIキーボード、下半分にオシロスコープを通じた信号の視覚的表現を提供します。現在、キーを押してもオシレーターの実装を提供しない限り、プラグインは音を出力しません。

MIDIコントローラーをお持ちの場合は、このチュートリアル全体で画面上のキーボードの代わりに接続することもできます。
DSPとは?
デジタル信号処理は、デジタルデータを操作して信号に対して特定の操作を行うことを含みます。デジタルオーディオ処理では、異なるドメインでオーディオデータを扱うことができます:
- 時間ドメイン:時間に関して分析が行われる1次元信号。
- 空間ドメイン:特定の空間に関して分析が行われる多次元信号。
- 周波数ドメイン:時間または空間を周波数の観点から表現する特定のドメイン。
高速フーリエ変換(FFT)
時間または空間ドメインの信号は、フーリエ変換と呼ばれる変換式を使用して周波数ドメインに変換できます。この変換関数の一般的な効率的な実装は、高速フーリエ変換またはFFTであり、JUCE DSPモジュールで見つけることができます。
FFTを使用すると、オーディオ信号をその周波数に分解し、これらの各周波数の大きさと位相情報を表現できます。逆関数を使用すると、信号を元のドメインに戻すことができるため、フィルタリングなどの個々の周波数成分を処理するのに非常に便利です。
有限/無限インパルス応答(FIR/IIR)
DSPには2つの主要なデジタルフィルタ設計があります:
- 有限インパルス応答フィルタ(FIR):各出力サンプルを以前の入力サンプルの関数として処理する安定した設計。FIRフィルタは線形位相にすることができ、設計がより簡単なことが多いですが、IIRフィルタよりも効率が低いです。
- 無限インパルス応答フィルタ(IIR):各出力サンプルを以前の入力および出力サンプルの関数として処理する、不安定になる可能性のある設計。IIRフィルタは以前の出力サンプルを使用するため内部フィードバックを作成し、設計が難しいですが、FIRフィルタよりも効率的になる可能性があります。
これらのフィルタ設計の中には、フィルタの鋭さと遷移周波数で発生するリップルの量を決定する様々な伝達関数があります。これらの設計の多くはアナログフィルタに触発されており、異なる伝達関数は異なるアナログの対応物をエミュレートしようとします。
JUCE DSPモジュールで見つけることができる伝達関数のいくつか:
- FIR伝達関数:Window、Kaiser、Transition、Least Squares、Half-Band Equiripple。
- IIR伝達関数:Butterworth、Chebyshev type 1、Chebyshev type 2、Elliptic、Half-Band Polyphase Allpass。
これらのフィルタ設計に興味がある場合は、このトピックについてより詳しく説明するオンラインリソースが豊富にありますが、このチュートリアルの目的では、始めるための基本以上をカバーしています。
信号処理のライフサイクル
AudioProcessorのオーディオアプリケーションライフサイクル(prepareToPlay()とgetNextAudioBlock()関数)と同様に、MPESynthesiserから派生したAudioEngineクラスのprepare()とrenderNextBlock()関数を実装する必要があります。
各DSPプロセッサも、適切な機能を確保するために以下のメソッドを実装する必要があります:
prepare():処理開始前にサンプルレートとブロックサイズを設定するために呼び出されます。process():処理コンテキストで提供される入力および出力バッファを処理します。reset():必要に応じてスムージングを伴うプロセッサの内部状態をリセットします。
プロセッサチェーン
DSPモジュールの便利なテンプレートクラスはjuce::dsp::ProcessorChainで、prepare()、process()、reset()メソッドを順番に自動的に呼び出すことで、異なるプロセスを直列に適用できます。
プロセッサをテンプレートタイプとして以下のように宣言します:
juce::dsp::ProcessorChain<juce::dsp::Oscillator<Type>, juce::dsp::Gain<Type>> processorChain;
これにより、processorChainインスタンスに対して直接すべてのプロセスを適用できます。
DSPモジュールの動作に関する基本的な知識を得たところで、信号の処理を始めましょう!
オシレーターの作成
CustomOscillatorクラスで、juce::dsp::Oscillatorとjuce::dsp::Gainプロセッサをこの上から下の順序で持つjuce::dsp::ProcessorChainを定義します[1]。オシレーターから出力されるレベルを調整できるように、ゲイン処理がオシレーターの出力に影響を与えるようにします。また、後で対応するプロセスをインデックスで明確に参照できるように、プロセッサインデックスを持つenumを定義します[2]。
enum {
oscIndex,
gainIndex // [2]
};
juce::dsp::ProcessorChain<juce::dsp::Oscillator<Type>, juce::dsp::Gain<Type>> processorChain; // [1]
};
prepare()関数で、プロセッサチェーン内の各プロセッサのprepare関数を順番に呼び出します[3]。
void prepare (const juce::dsp::ProcessSpec& spec)
{
processorChain.prepare (spec); // [3]
}
reset()関数で、プロセッサチェーン内の各プロセッサのreset関数を順番に呼び出します[4]。
void reset() noexcept
{
processorChain.reset(); // [4]
}
次に、オシレーターがオーディオ信号を生成するために使用する周期関数を定義します。簡単な例として、サイン波から始めます。
コンストラクタで、プロセスのインデックスを提供してprocessorChain.get<>()メソッドを使用してOscillatorへの参照を取得します[5]。ラムダ関数とstd::sin関数を使用してオシレーターにサイン波を提供することでオシレーターを初期化しましょう[6]。
ルックアップテーブルは、提供された離散ポイントの数に応じて高コストな算術演算を近似します。この場合、128ポイントを使用しましょう。
public:
//==============================================================================
CustomOscillator()
{
auto& osc = processorChain.template get<oscIndex>(); // [5]
osc.initialise ([] (Type x) { return std::sin (x); }, 128); // [6]
}
オシレーターの周波数を設定するには、前のステップと同様に参照を取得し、setFrequency()メソッドを呼び出す必要があります[7]。
void setFrequency (Type newValue, bool force = false)
{
auto& osc = processorChain.template get<oscIndex>();
osc.setFrequency (newValue, force); // [7]
}
ゲインプロセッサとそのsetGainLinear()メソッドも同様のプロセスです[8]。
void setLevel (Type newValue)
{
auto& gain = processorChain.template get<gainIndex>();
gain.setGainLinear (newValue); // [8]
}
process()関数で、プロセッサチェーン内の各プロセッサのprocess関数を順番に呼び出すことができます[9]。
template <typename ProcessContext>
void process (const ProcessContext& context) noexcept
{
processorChain.process (context); // [9]
}
CustomOscillatorクラスで上記の変更を実装した後にこのコードを実行すると、JUCE DSPモジュールを使用したシンプルなサイン波シンセサイザーが聴けるはずです。

オシレーター波形の変更
オシレーターの波形をノコギリ波に変更して、シンセサイザーをもう少し面白くしましょう。
ノコギリ波関数のstdバージョンにアクセスできないため、jmap関数を使用して手動で値のマッピングを実装する必要があります。これを行うには、-Pi .. Piの範囲を-1 .. 1にマッピングして、-1から1への線形ランプを提供します。ノコギリ波には2つのブレークポイントしかないため、ルックアップテーブルに2つの離散ポイントのみを提供する必要があります。
public:
//==============================================================================
CustomOscillator()
{
auto& osc = processorChain.template get<oscIndex>();
osc.initialise ([] (Type x) {
return juce::jmap (x,
Type (-juce::MathConstants<double>::pi),
Type (juce::MathConstants<double>::pi),
Type (-1),
Type (1));
},
2);
}
プログラムを実行すると、より攻撃的な異なるサウンドが得られるはずです。

演習:三角波または矩形波でオシレーターを初期化し、そのサウンドを聴いてみてください。ホワイトノイズオシレーターを実装できますか?
2番目のオシレーターの追加
ほとんどのアナログシンセサイザーには複数のオシレーターがあり、より厚いサウンドを得るための一般的なトリックは、周波数がわずかにデチューンされた2番目のオシレーターを追加することです。Voiceクラスを変更してそれを試してみましょう。
プロセッサチェーンに2番目のCustomOscillatorテンプレートタイプを追加し[1]、enumに対応するインデックスを追加します[2]。
private:
//==============================================================================
juce::HeapBlock<char> heapBlock;
juce::dsp::AudioBlock<float> tempBlock;
enum {
osc1Index,
osc2Index, // [2]
masterGainIndex
};
juce::dsp::ProcessorChain<CustomOscillator<float>, CustomOscillator<float>, juce::dsp::Gain<float>> processorChain; // [1]
//...
};
noteStarted()関数で、2番目のオシレーターの周波数を現在演奏されているノートに設定し、1%ピッチアップします[3]。ベロシティは最初のオシレーターと同じレベルに保つことができます[4]。
void noteStarted() override
{
auto velocity = getCurrentlyPlayingNote().noteOnVelocity.asUnsignedFloat();
auto freqHz = (float) getCurrentlyPlayingNote().getFrequencyInHertz();
processorChain.get<osc1Index>().setFrequency (freqHz, true);
processorChain.get<osc1Index>().setLevel (velocity);
processorChain.get<osc2Index>().setFrequency (freqHz * 1.01f, true); // [3]
processorChain.get<osc2Index>().setLevel (velocity); // [4]
}
notePitchbendChanged()関数でピッチベンドが適用されたときに、デチューンされた周波数が同じままであることを確認しましょう[5]。
void notePitchbendChanged() override
{
auto freqHz = (float) getCurrentlyPlayingNote().getFrequencyInHertz();
processorChain.get<osc1Index>().setFrequency (freqHz);
processorChain.get<osc2Index>().setFrequency (freqHz * 1.01f); // [5]
}
プログラムを実行して、どのように聴こえるか確認しましょう。

演習:周波数を1%ピッチダウンした3番目のオシレーターを追加してみてください。サウンドはより厚くなりますか?
ラダーフィルターの追加
シンセサイザーにフィルタ設計を導入しましょう。ラダーフィルタープロセッサは、Moogシンセサイザーからのよく知られたアナログ設計に触発されており、このプロジェクトで使用するものです。今までに、プロセッサチェーンにプロセッサを追加するタスクに慣れているはずです。
Voiceクラスで、プロセッサチェーンにjuce::dsp::LadderFilterを追加し[1]、enumに対応するインデックスを追加します[2]。
juce::HeapBlock<char> heapBlock;
juce::dsp::AudioBlock<float> tempBlock;
enum {
osc1Index,
osc2Index,
filterIndex, // [2]
masterGainIndex
};
juce::dsp::ProcessorChain<CustomOscillator<float>, CustomOscillator<float>, juce::dsp::LadderFilter<float>, juce::dsp::Gain<float>> processorChain; // [1]
前述のように、フィルタープロセッサの参照を取得し、カットオフ周波数を1kHz [3]、レゾナンスを0.7 [4]に設定します。
Voice()
{
auto& masterGain = processorChain.get<masterGainIndex>();
masterGain.setGainLinear (0.7f);
auto& filter = processorChain.get<filterIndex>();
filter.setCutoffFrequencyHz (1000.0f); // [3]
filter.setResonance (0.7f); // [4]
信号の高周波数が減衰し、よりこもったサウンドになるはずです。

演習:異なるレゾナンス値とカットオフ周波数を試して、出力を聴いてみてください。現在、フィルターは12dB/octave減衰のローパスフィルターです。24dB/octave減衰のハイパスフィルターにできますか?
LFOによる信号の変調
クラシックなアナログシンセサウンドに近づいてきましたが、他に何があるでしょうか?もちろん、変調するLFOです。
低周波オシレーターは、変調したい別のパラメータへの制御信号として機能します。その周波数は通常非常に低く、人間の可聴範囲以下であるため、以前のオシレーターのようにプロセッサチェーンにオシレーターを追加すべきではありません。今回は、Voiceクラスで新しいOscillatorを通常のメンバー変数として宣言します[1]。
static constexpr size_t lfoUpdateRate = 100;
size_t lfoUpdateCounter = lfoUpdateRate;
juce::dsp::Oscillator<float> lfo; // [1]
ラダーフィルターのカットオフ周波数への遅くて滑らかな変調変化を生成するために、VoiceコンストラクタでLFOをサイン波[2]として3Hzのレート[3]で初期化します。
lfo.initialise ([] (float x) { return std::sin (x); }, 128);
lfo.setFrequency (3.0f);
}
オーディオ処理サンプルレートと同じ頻度でLFOを更新する必要がないため、prepare()関数でLFOサンプルレートを設定するためにサンプルレートをLFO更新レートで割ります[4]。この場合、LFOを100分の1の頻度で更新することを決定します。
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 }); // [4]
}
以下のfor()ループでは、100サンプルごとにのみカットオフ周波数を変更します。まず、LFOで単一のサンプルを処理するためにprocessSample()関数を呼び出し[5]、その戻り値を目的の変調範囲にマッピングします[6]。この場合、カットオフ周波数を100Hzから2kHzまで変調したいと思います。最後に、新しいカットオフ周波数をラダーフィルターに適用します[7]。
void renderNextBlock (juce::AudioBuffer<float>& outputBuffer, int startSample, int numSamples) override
{
auto output = tempBlock.getSubBlock (0, (size_t) numSamples);
output.clear();
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;
if (lfoUpdateCounter == 0)
{
lfoUpdateCounter = lfoUpdateRate;
auto lfoOut = lfo.processSample (0.0f); // [5]
auto curoffFreqHz = juce::jmap (lfoOut, -1.0f, 1.0f, 100.0f, 2000.0f); // [6]
processorChain.get<filterIndex>().setCutoffFrequencyHz (curoffFreqHz); // [7]
}
}
juce::dsp::AudioBlock<float> (outputBuffer)
.getSubBlock ((size_t) startSample, (size_t) numSamples)
.add (tempBlock);
}
これでUFOタイプのサイレンサウンドが聴こえるはずです。

演習:フィルターレゾナンスやオシレーター周波数などの異なるパラメータを変調してみてください。
シンプルなリバーブの追加
現在、シンセサイザーを演奏すると、サウンドが非常にドライであることに気づいたかもしれません。信号に深みを加えるためにシンプルなリバーブを追加しましょう。シンセサウンド全体にリバーブを適用するために、AudioEngineクラスでエフェクトチェーンを作成し、juce::dsp::Reverbテンプレートタイプをエフェクトチェーンに追加します[1]。そのインデックスも追加します[2]。
enum {
reverbIndex // [2]
};
juce::dsp::ProcessorChain<juce::dsp::Reverb> fxChain; // [1]
};
プロセッサチェーンでprepare()関数を呼び出します[3]。
void prepare (const juce::dsp::ProcessSpec& spec) noexcept
{
setCurrentPlaybackSampleRate (spec.sampleRate);
for (auto* v : voices)
dynamic_cast<Voice*> (v)->prepare (spec);
fxChain.prepare (spec); // [3]
}
エフェクトチェーンを処理するには、処理チェーンにコンテキストを渡すためにAudioBufferから正しいAudioBlockを取得する必要があります。まず、AudioBufferを使用可能なAudioBlockに変換し[4]、getSubBlock()メソッドを使用して操作するサンプルの正しい部分を参照します[5]。これで、このAudioBlockから処理コンテキストを取得し[6]、それでエフェクトチェーンを処理できます[7]。
void renderNextSubBlock (juce::AudioBuffer<float>& outputAudio, int startSample, int numSamples) override
{
MPESynthesiser::renderNextSubBlock (outputAudio, startSample, numSamples);
auto block = juce::dsp::AudioBlock<float> (outputAudio); // [4]
auto blockToUse = block.getSubBlock ((size_t) startSample, (size_t) numSamples); // [5]
auto contextToUse = juce::dsp::ProcessContextReplacing<float> (blockToUse); // [6]
fxChain.process (contextToUse); // [7]
}
シンセには、信号の終わりに滑らかなリバーブテールが追加されるはずです。

このコードの修正版のソースコードは、デモプロジェクトのDSPIntroductionTutorial_02.hファイルにあります。
まとめ
このチュートリアルでは、オーディオバッファを操作し、JUCE DSPモジュールを使用して信号を処理する方法を学びました。特に以下のことを行いました:
- 複数のオシレーターを使用してウェーブテーブルシンセを作成しました。
- ノコギリ波やサイン波を含む異なる波形で遊びました。
- フィルターを実装し、LFOでカットオフ周波数を操作しました。
- 信号に広がりを加えるためにシンプルなリバーブを追加しました。
このチュートリアルのパート2をチェックして、ディストーションとコンボリューションを追加する方法を学びましょう:チュートリアル:ウェーブシェイピングとコンボリューションによるディストーションの追加
このチュートリアルのパート3にスキップして、ディレイラインを追加する方法を学びましょう:チュートリアル:ディレイラインによる弦モデルの作成