メインコンテンツまでスキップ

チュートリアル:DSP入門

📚 Source Page

デジタル信号処理とオーディオバッファ操作の領域を理解してください。 JUCE DSPモジュールの基礎、そして、そのクラスを自分のオーディオアプリケーションやプラグインに組み込む方法を学びます。

レベル:Advanced

プラットフォーム:Windows, macOS, Linux

プラグイン形式:VST, AU, Standalone

クラス: dsp::ProcessorChain, dsp::Gain, dsp::Oscillator

警告

このプロジェクトには、C++14 の機能をサポートするコンパイラが必要です。 Xcode と Visual Studio の最近のバージョンには、このサポートが含まれています。

はじめる

このチュートリアルを読む前に、シンセシスの基本を理解し、MPEに入門していることを確認してください。 MPEについてもっと知りたい方は、こちらのチュートリアルをご覧ください:マルチ・ポリフォニック・シンセサイザーを作る

このチュートリアルのデモプロジェクトはこちらからダウンロードしてください: PIP | ZIP プロジェクトを解凍し、最初のヘッダーファイルをProjucerで開きます。

このステップで助けが必要な場合は、チュートリアルを参照してください: Projucerパート1:Projucerを使い始める

デモプロジェクト

プロジェクトはプラグインとして構想されていますが、IDEで適切なデプロイメント・ターゲットを選択することで、スタンドアロン・アプリケーションとして実行することができます。Xcodeでは、以下のスクリーンショットのように、メインウィンドウの左上でターゲットを変更することができます:

Changing the deployment target in Xcode
Changing the deployment target in Xcode

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

The demo project plugin window
The demo project plugin window
注記

MIDIコントローラーをお持ちの場合は、このチュートリアル中、画面上のキーボードを使う代わりに、MIDIコントローラーを接続することもできます。

DSPとは?

デジタル信号処理では、デジタルデータを操作して、信号に対して特定の処理を行います。デジタルオーディオ処理では、異なるドメインのオーディオデータを扱うことができます:

  • 時間領域:時間に関して分析が行われる一次元信号
  • 空間領域:ある空間に関して分析が行われる多次元信号
  • 周波数領域:時間または空間を周波数で表す特定の領域

高速フーリエ変換(FFT)

時間領域または空間領域の信号は、フーリエ変換と呼ばれる変換式を用いて周波数領域に変換することができます。この変換関数の一般的な効率的実装は高速フーリエ変換(FFT)であり、JUCE DSPモジュールで遭遇することがあります。

FFTは、オーディオ信号を周波数に分解し、それぞれの周波数の大きさと位相情報を表現することができます。逆関数を使用すると、信号を元のドメインに戻すことができるため、フィルタリングなど個々の周波数成分を処理するのに非常に便利です。

有限/無限インパルス応答(FIR/IIR)

DSP には主に2つのデジタル・フィルター設計がある:

  • 有限インパルス応答フィルター(FIR):各出力サンプルを以前の入力サンプルの関数として処理する安定した設計。FIRフィルタは線形位相にすることができ、多くの場合、設計は単純だがIIRフィルタより効率は低い。
  • 無限インパルス応答フィルター(IIR):各出力サンプルを以前の入力サンプルと出力サンプルの関数として処理する、不安定な設計の可能性がある。IIRフィルタは以前の出力サンプルを使用するため内部フィードバックが発生し、設計は難しいがFIRフィルタよりも効率的である。

これらのフィルター設計の中には、フィルターの鋭さや遷移周波数で発生するリップルの量を決定する多くの異なる伝達関数がある。これらの設計の多くは、アナログ・フィルターにインスパイアされたものであり、異なる伝達関数は、異なるアナログ対応をエミュレートしようとしている。

JUCE DSP モジュールには、以下のような転送機能があります:

  • FIR伝達関数ウィンドウ、カイザー、トランジション、最小二乗法、ハーフバンドイコライザップル
  • IIR伝達関数:バターワース、チェビシェフ・タイプ1、チェビシェフ・タイプ2、楕円、ハーフバンド・ポリフェーズ・オールパス

このようなフィルター設計に興味がある方は、このトピックについてより深く解説した資料をオンラインでたくさん見つけることができますが、このチュートリアルの目的上、私たちが始めるための基本的なこと以上のことを取り上げました。

信号処理のライフサイクル

prepareToPlay() 関数と getNextAudioBlock() 関数を持つ AudioProcessor のオーディオ・アプリケーション・ライフサイクルと同様に、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::ProcessorChainを定義し、juce::dsp::Oscillatorとjuce::dsp::Gainプロセッサをトップダウンの順序で並べます [1] 。 ゲイン処理がオシレータの出力に影響し、出力されるレベルをトリミングできるようにしたいのです。 また、プロセッサー・インデックス[2]でenumを定義し、後でそのインデックスから対応する処理を明確に参照できるようにします。

    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()関数の中で、プロセッサ・チェーンの各プロセッサのリセット関数を順次呼び出す [4]。

    void reset() noexcept
{
processorChain.reset(); // [4]
}

次に、オシレーターがオーディオ信号を生成する周期関数を定義します。

コンストラクタでプロセスのインデックスを指定してオシレータへの参照を取得し、processorChain.get<>()メソッドを使います[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()関数では、プロセッサー・チェーン内の各プロセッサー のプロセス関数を順次呼び出すことができる[9]。

    template <typename ProcessContext>
void process (const ProcessContext& context) noexcept
{
processorChain.process (context); // [9]
}

CustomOscillatorクラスで上記の変更を実装した後にこのコードを実行すると、JUCE DSPモジュールを使った簡単なサイン波合成を聞くことができるはずです。

Sine wave synthesiser with the JUCE DSP module
Sine wave synthesiser with the JUCE DSP module

オシレーター波形の変更

シンセサイザーのオシレーター波形をノコギリ波にして、少しエキサイティングにしてみよう。

ノコギリ波関数の標準バージョンは使えないので、jmap関数を使って手動で値をマッピングする必要がある。 そのためには、-Pi . ノコギリ歯には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);
}

このプログラムを実行することで、よりアグレッシブなサウンドが得られるはずだ。

Sawtooth synthesiser with the JUCE DSP module
Sawtooth synthesiser with the JUCE DSP module
エクササイズ

三角波や矩形波でオシレータを初期化して、その音を聴いてみてください。ホワイトノイズのオシレーターを実装できますか?

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] 。 ベロシティは、1つ目のオシレーターと同じラヴェルに保つことができます [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]
}

プログラムを実行し、どのように聞こえるか見てみよう。

Synthesiser with a second sawtooth oscillator
Synthesiser with a second sawtooth oscillator
エクササイズ

周波数を1%下げた3つ目のオシレーターを追加します。 音は太くなりますか?

ラダーフィルターの追加

シンセサイザーにフィルター・デザインを導入しよう。 ラダー・フィルター・プロセッサーは、Moogシンセサイザーの有名なアナログ・デザインにインスパイアされたもので、私たちのプロジェクトではこれを使います。 ここまでで、プロセッサー・チェーンにプロセッサーを追加する作業には慣れたはずです。

juce::dsp::LadderFilter をプロセッサチェイン [1] に追加し、対応するインデックスを Voice クラスの 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]

信号の高域が減衰し、よりこもった音になるはずです。

Synthesiser with a ladder filter
Synthesiser with a ladder filter
エクササイズ

レゾナンス値やカットオフ周波数を変えてみて、出力を聴いてみてください。 現時点では、フィルターは12dB/octaveの減衰を持つローパスフィルターです。 24dB/オクターブ減衰のハイパスフィルターにすることはできますか?

LFOで信号を変調する

クラシックなアナログ・シンセのサウンドに近づいた今、これ以上何があるだろうか? もちろん、モジュレーションLFOだ。

低周波オシレーターは、変調したい別のパラメーターのコントロール信号として機能します。 オシレーターの周波数は通常非常に低く、人間の可聴域を下回るため、これまでのオシレーターのようにプロセッサーチェーンに追加する必要はありません。 今回は、新しいOscillatorをVoiceクラスの通常のメンバ変数[1]として宣言します。

static constexpr size_t lfoUpdateRate = 100;
size_t lfoUpdateCounter = lfoUpdateRate;
juce::dsp::Oscillator<float> lfo; // [1]

ラダー・フィルターのカットオフ周波数にゆっくりとした滑らかなモジュレーション変化を与えるには、VoiceコンストラクタでLFOを3Hzの正弦波[2]として初期化します[3]。

    lfo.initialise ([] (float x) { return std::sin(x); }, 128);
lfo.setFrequency (3.0f);
}

オーディオ処理のサンプルレートほど頻繁にLFOを更新する必要はないので、サンプルレートをLFOの更新レートで割って、prepare() 関数でLFOのサンプルレートを設定する[4]。 この場合、LFOの更新頻度を100回減らすことにします。

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サンプルごとにカットオフ周波数を変更するだけです。 まずprocessSample()関数を呼び出してLFOの1サンプルを処理し[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型のサイレン音が聞こえるはずだ。

Synthesiser with an LFO
Synthesiser with an LFO
エクササイズ

フィルターのレゾナンスやオシレーターの周波数など、さまざまなパラメーターを変調してみてください。

シンプルなリバーブの追加

シンセサイザーを再生すると、サウンドが非常にドライであることにお気づきかもしれませんので、シンプルなリバーブを追加して信号に深みを加えましょう。 シンセサウンド全体にリバーブを適用するには、AudioEngineクラスでエフェクトチェーンを作成し、juce::dsp::Reverbテンプレートタイプを、インデックス[2]とともにエフェクトチェーンに追加します[1]。

    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]
}

これでシンセは、信号の最後にスムースなリバーブのテールが追加されるはずです。

Synthesiser with reverb
Synthesiser with reverb
注記

この修正版のソース・コードは、デモ・プロジェクトのDSPIntroductionTutorial_02.h ファイルにあります。

概要

このチュートリアルでは、JUCE DSP モジュールを使ってオーディオバッファを操作し、信号を処理する方法を学びました。 特に

  • 複数のオシレーターを使ってウェーブテーブル・シンセを作成
  • ノコギリ波やサイン波など、さまざまな波形で演奏
  • フィルターを実装し、LFOでカットオフ周波数を操作
  • 信号に広がりを持たせるためにシンプルなリバーブを追加
注記

ディストーションとコンボリューションを加えるこのチュートリアルのパート2をご覧ください:チュートリアル ウェーブシェイピングとコンボリューションで歪みを加える

ディレイ・ラインを追加するには、このチュートリアルのパート3に進んでください:チュートリアル ディレイラインを使ったストリング・モデルの作成

関連項目