チュートリアルディレイラインを使ったストリング・モデルの作成
フィジカルモデリングによるリアルなストリングスモデルの実装。ディレイ・ラインを組み込んで、ステレオ音場に複雑なエコー・パターンを作り出す。
レベル上級
プラットフォーム:Windows, macOS, Linux
プラグイン形式:VST, AU, スタンドアロン
クラス: dsp::ProcessorChain,dsp::Gain,dsp::Oscillator,dsp::Convolution,dsp::WaveShaper,dsp::Reverb
このプロジェクトには、C++14の機能をサポートするコンパイラが必要です。XcodeとVisual Studioの最近のバージョンには、このサポートが含まれています。
スタート
このチュートリアルはTutorial: Add distortion through waveshaping and convolution.もしまだなら、まずそのチュートリアルを読むべきだ。
このチュートリアルのデモ・プロジェクトのダウンロードはこちらから:PIP|ZIP.プロジェクトを解凍し、最初のヘッダーファイルをProjucerで開く。
このプロジェクトのPIPバージョンを使用する場合は、必ずResources
フォルダを生成されたProjucerプロジェクトに追加します。
このステップでヘルプが必要な場合は、以下を参照してください。Tutorial: Projucer Part 1: Getting started with the Projucer.
デモ・プロジェクト
プロジェクトはプラグインとして構想されていますが、IDEで適切なデプロイメント・ターゲットを選択することで、スタンドアロン・アプリケーションとして実行することができます。Xcodeでは、以下のスクリーンショットのように、メインウィンドウの左上でターゲットを変更することができます:
デモ・プロジェクトでは、プラグインの上半分にMIDIキーボ ードが画面上に表示され、下半分にはオシロスコープを通して信号が視覚的に表示される。現在、鍵盤が押されると、プラグインはいくつかのリバーブとディストーションを加えた基本的なオシレーター・サウンドを出力します。
チュートリアルの各ステップでの変化を明確に聞くために、自由にエフェクトを取り除くことができます。AudioEngine
クラスである。
MIDIコントローラーをお持ちの場合は、このチュートリアル中、画面上のキーボードを使う代わりに、MIDIコントローラーを接続することもできます。
はじめに
このチュートリアルでは、異なる方法で信号処理を可能にする2つの新しいDSPコンセプトを紹介します:ディレイ・ラインとフィジカル・モデリングです。
このDSP用語の定義から始めよう。
ディレイ・ラインとは?
ディレイ・ラインは、残響空間のシミュレーション、サウンド・シンセシス、フィルターの実装、ディレイ、コーラス、フェイザー、フランジャーなどの古典的なタイムベース・エフェクトなど、幅広い用途に使用できるDSPの基本ツールです。
基本的に、ディレイラインは非常にシンプルで、ある信号をサンプル数だけ遅らせることができます。複数のディレイ・ラインを使い、別々の信号を異なる間隔で合計することで、デジタル信号処理の大部分を作り出すことができます。
アナログ領域では、遅延 線は、波の伝搬を遅延させるために、バネのような実際の物理的な拡張を導入することで実装されていた。デジタル領域では、遅延線は多くの場合、サーキュラー・バッファと呼ばれるデータ構造を使って実装される。
サーキュラー・バッファは、基本的に、サンプル・バッファ・ブロックのサイズに一致するサーキュラー・データ構造を作成するために、インデックスがそれ自身をラップする配列として実装することができる。これにより、前のブロックに含まれるすべてのサンプルを現在のブロックにアクセスできるように保存し、次の反復のために現在のサンプルブロックによって上書きされるようにすることができます。
このチュートリアルでは、ディレイ・ラインを実装する方法として、サーキュラー・バッファを取り上げます。
フィジカル・モデリングとは何か?
フィジカル・モデリングは、サウンドを生成するために数学的および物理的モデルに依存するサウンド合成手法を示す。他の合成手法とは異なり、サンプルを出発点として使用せず、素材の研究を通じて物理的な意味で音がどのように生成されるかに焦点を当てる。
そのひとつがデジタル導波管と呼ばれるモデルで、音響波が管やパイプの中を伝搬する物理モデルに基づいている。これらの波の境界に対する反射は、遅延線を用いて効率的に計算することができ、 このモデルを用いて弦楽器などの多くの楽器の音を生成することができます。
導波管ストリングモデル
一言で言えば、導波管ストリング・モデルは、振動するストリングは、反対方向に進み、2つの端点で跳ね返る2つの波を使ってモデル化できるという概念に基づいています。この2つの波の組み合わせは、最終的に弦を弾いたときの理想的な動きをシミュレートし、2つのディレイライン(前方ディレイラインと後方ディレイライン)を使って実装することができます。
しかし、この理想的な弦のモデルは、減衰が考慮されていないため、現在の状態で静止することはない。したがって、波が境界で方向を変え、その極性が反転するとき、波の変位を減らすために減衰係数を組み込むことができる。
このモデルで考慮すべき他の変数は、弦をはじく位置と、弦が振動する音を拾う位置である。したがって、空間上の特定の位置から音を聴くのと同じように、2つの波が伝播する弾く位置と拾う位置を統合しなければならない。
最後になるが、物理的な弦で起こる自然現象に、高い周波数の減衰が低い周波数より速いというものがある。これは、境界の一端にローパスフィルターを追加するだけで、この減衰時間の不一致をシミュレートすることができ、我々のモデルに簡単に組み込むことができる。
このチュートリアルでは、ディレイ・ラインを使ってこのデジタル導波管 モデルを実装し、弦を弾いたときに弦の境界で反射する波をシミュレートします。
ディレイラインの導入
まず、単純なディレイ・ラインをベクターを使って円形バッファとして実装してみよう。
の中でDelayLine
クラスには、実装を容易にするために、次のようないくつかの説明不要のヘルパー関数がすでに定義されている。size()
そしてresize()
メソッドclear()
関数とback()
関数は、バッファ内の最も新しいサンプルを取得します。
まずpush()
この関数は、最近追加されたサンプルを上書きして新しいサンプルを追加します。[1]そして、そのインデックスをサーキュラーバッファのサイズでラップして、最も新しいインデックス変数を更新する。[2]:
void push (Type valueToAdd) noexcept
{
rawData[leastRecentIndex] = valueToAdd; // [1]
leastRecentIndex = leastRecentIndex == 0 ? size() - 1 : leastRecentIndex - 1; // [2]
}
そして、完成させる。get()
関数の引数で指定されたオフセットに位置するサンプルを返します。[3].ここでは、遅延がバッファーのサイズを超えないようにしていることに注意。
Type get (size_t delayInSamples) const noexcept
{
jassert (delayInSamples >= 0 && delayInSamples < size());
return rawData[(leastRecentIndex + 1 + delayInSamples) % size()]; // [3]
}
次にset()
関数の引数で指定されたオフセットにサンプルを代入し、インデックスがベクトル[4].ここでも、遅延がバッファのサイズを超えないようにする。
void set (size_t delayInSamples, Type newValue) noexcept
{
jassert (delayInSamples >= 0 && delayInSamples < size());
rawData[(leastRecentIndex + 1 + delayInSamples) % size()] = newValue; // [4]
}
これでシンプルなディレイ・ラインの実装は完了だ。
ディレイ効果を組み込む
基本的なディレイ・ライン・クラスを実装したので、シグナル・チェインにステレオ・ディレイ・エフェクトを組み込んでみましょう。
の中でDelay
クラスには、ディレイ・エフェクトの動作を変更するために微調整できるパラメーターが複数あり、個々のチャンネルのディレイ・タイム、許容される最大ディレイ・タイム、エフェクトのドライ/ウェット・レベル、フィードバックの量などが含まれます。
これらのパラメーターとディレイ・ラインの実装を使えば、さまざまなディレイ・エフェクトを思い通りに作ることができるが、まずはコンストラクターで定義されているデフォルトのパラメーターから見ていこう:
{
public:
//==============================================================================
Delay()
{
setMaxDelayTime (2.0f);
setDelayTime (0, 0.7f);
setDelayTime (1, 0.5f);
setWetLevel (0.8f);
setFeedback (0.5f);
}
これらのヘルパー関数は、主にパラメータを格納するために対応するメンバ変数を設定しますが、中にはパラメータの変更に対応するためにデータ構造のサイズ変更を必要とするものもあります。
そのようなケースのひとつがsetMaxDelayTime()
関数を呼び出す。updateDelayLineSize()
ヘルパー関数[1]:
void setMaxDelayTime (Type newValue)
{
jassert (newValue > Type (0));
maxDelayTime = newValue;
updateDelayLineSize(); // [1]
}
ベクターのサイズを変更することで、すべての遅延ラインのサーキュラーバッファが、最大遅延時間までのどのような遅延時間にも対応できる十分な大きさになるようにする、以下の関数を完成させる。[2]:
void updateDelayLineSize()
{
auto delayLineSizeSamples = (size_t) std::ceil (maxDelayTime * sampleRate);
for (auto& dline : delayLines)
dline.resize (delayLineSizeSamples); // [2]
}
もうひとつの注目すべきケースはsetDelayTime()
パラメータが変更されると、各チャンネルのupdateDelayTime()
ヘルパー関数[3]以下の通りである:
void setDelayTime (size_t channel, Type newValue)
{
if (channel >= getNumChannels())
{
jassertfalse;
return;
}
jassert (newValue >= Type (0));
delayTimes[channel] = newValue;
updateDelayTime(); // [3]
}
新しいパラメータの変更に基づいて、すべてのチャンネルの遅延時間をサンプル単位で再計算する以下のヘルパー関数を実装する。[4]:
void updateDelayTime() noexcept
{
for (size_t ch = 0; ch < maxNumChannels; ++ch)
delayTimesSample[ch] = (size_t) juce::roundToInt (delayTimes[ch] * sampleRate);
}
の中でreset()
関数で、各チャンネルのフィルターをリセットします。[5]これはチュートリアルの次のセクションで使用するもので、ディレイラインに残っている古いサンプルをクリアします。[6]:
void reset() noexcept
{
for (auto& f : filters)
f.reset(); // [5]
for (auto& dline : delayLines)
dline.clear(); // [6]
}
の中でprepare()
関数では、ディレイラインのサイズが[7]とサンプル単位の遅延時間[8]サンプル・ブロックの間でサンプル・レートが変更された場合でも、サンプル・ブロックのサンプル・レートは正しいままです。[9]:
void prepare (const juce::dsp::ProcessSpec& spec)
{
jassert (spec.numChannels <= maxNumChannels);
sampleRate = (Type) spec.sampleRate;
updateDelayLineSize(); // [7]
updateDelayTime(); // [8]
filterCoefs = juce::dsp::IIR::Coefficients::makeFirstOrderLowPass (sampleRate, Type (1e3)); // [9]
for (auto& f : filters)
{
f.prepare (spec);
f.coefficients = filterCoefs;
}
}
jassert#define jassert(expression)Platform-independent assertion macro.Definition juce_PlatformDefs.h:177
さて、次はprocess()
関数を使用して、ディレイ効果を実際に実装する:
template
void process (const ProcessContext& context) noexcept
{
auto& inputBlock = context.getInputBlock();
auto& outputBlock = context.getOutputBlock();
auto numSamples = outputBlock.getNumSamples();
auto numChannels = outputBlock.getNumChannels();
jassert (inputBlock.getNumSamples() == numSamples);
jassert (inputBlock.getNumChannels() == numChannels);
for (size_t ch = 0; ch < numChannels; ++ch)
{
auto* input = inputBlock .getChannelPointer (ch);
auto* output = outputBlock.getChannelPointer (ch);
auto& dline = delayLines[ch];
auto delayTime = delayTimesSample[ch];
auto& filter = filters[ch];
for (size_t i = 0; i < numSamples; ++i)
{
auto delayedSample = dline.get (delayTime); // [10]
auto inputSample = input[i]; // [11]
auto dlineInputSample = std::tanh (inputSample + feedback * delayedSample); // [12]
dline.push (dlineInputSample); // [13]
auto outputSample = inputSample + wetLevel * delayedSample; // [14]
output[i] = outputSample; // [15]
}
}
}
- [10]まず、各チャンネルのバッファブロックにある各サンプルについて、対応するディレイラインから遅延したサンプルを取り出します。
- [11]次に入力ブロックから現在のサンプルを取得する。
- [12]次に、入力サンプルと、フィードバック・パラメータで重み付けされたディレイ・ライン出力とをミックスすることによって、ディレイ・ラインにプッシュされるサンプルを計算する。
std::tanh()
.双曲線正接関数は、合計されたサンプルをクリップすることなく、2つの信号を滑らかに結合することを可能にし、自然な減衰を提供する。 - [13]次に、前のステップで計算したサンプルをディレイラインにプッシュします。
- [14]最後に、入力サンプルと、dry/wet パラメーターで重み付けされたディレイライン出力をミックスして、出力サンプルを計算する。
- [15]次に、そのサンプルを出力ブロックにアサインします。
で上記の変更を行った後にこのコードを実行すると、次のようになる。Delay
クラスでは、オシレーター信号のディレイ効果を聴くことができるはずです。
様々なディレイ・パラメーターを試して、ディレイ・パターンがステレオ・フィールド内でどのように変化するかに注目してください。
ディレイ効果のフィルター
ほとんどのディレイ・エフェクトは、自然界で発生するような、よりリアルなサウンドを提供するために、信号が繰り返し減衰する際のフィルタリングを組み込んでいます。それでは、ディレイ・サウンドにフィルターをかけてみましょう。
の1行を変更するだけで、簡単に実現できる。process()
の機能である。Delay
クラスを以下のように変更した:
template
void process (const ProcessContext& context) noexcept
{
//...
for (size_t ch = 0; ch < numChannels; ++ch)
{
//...
for (size_t i = 0; i < numSamples; ++i)
{
auto delayedSample = filter.processSample (dline.get (delayTime)); // [1]
auto inputSample = input[i];
auto dlineInputSample = std::tanh (inputSample + feedback * delayedSample);
dline.push (dlineInputSample);
auto outputSample = inputSample + wetLevel * delayedSample;
output[i] = outputSample;
}
}
}
ここでは単にprocessSample()
関数は、ディレイラインから遅延されたサンプルをフィルターオブジェクトに渡します。[1].
でフィルタータイプをハイパスフィルターに変更する。prepare()
係数を入れ替えてmakeFirstOrderHighPass()
機能[2]この通りである:
void prepare (const juce::dsp::ProcessSpec& spec)
{
//...
filterCoefs = juce::dsp::IIR::Coefficients::makeFirstOrderHighPass (sampleRate, Type (1e3)); // [2]
//...
}
プログラムを実行すると、リピート回数が増えるにつれて、より明るい遅延音が得られるはずだ。
様々な種類のフィルターを使って遅延音を処理し、繰り返される信号の音がどのように変化するかを試してみてください。
導波管ストリングモデルの統合
遅延効果のためにディレイ・ラインを都合よく実装したので、同じクラスを使って導波管ストリング・モデルを統合するために同じデータ構造を使うことができます。
の中でWaveguideString
クラスでは、ストリング・モデルの挙動を変えるために微調整できるパラメーターが複数あり、トリガー・ポジション、ピックアップ・ポジション、弦のダンピングの減衰時間などがある。
デフォルト・パラメーターはコンストラクターで定義され、そのコンストラクターは対応するメンバー変数を次のように設定する:
public:
//==============================================================================
WaveguideString()
{
setTriggerPosition (Type (0.2));
setPickupPosition (Type (0.8));
setDecayTime (Type (0.5));
}
これらのヘルパー関数はupdateParameters()
この関数は、遅延ラインのサイズ、遅延ラインに対するピックアップ・インデックス、前方の遅延ラインに対するトリガー・インデックス、フィルター係数、減衰時間に基づく減衰係数など、さまざまな変数を初期化する。
以下に説明するように、このヘルパー関数の実装を追加する:
void updateParameters()
{
auto length = (size_t) juce::roundToInt (sampleRateHz / freqHz); // [1]
forwardDelayLine .resize (length);
backwardDelayLine.resize (length);
forwardPickupIndex = (size_t) juce::roundToInt (jmap (pickupPos, Type (0), Type (length / 2 - 1))); // [2]
backwardPickupIndex = length - 1 - forwardPickupIndex;
forwardTriggerIndex = (size_t) juce::roundToInt (jmap (triggerPos, Type (0), Type (length / 2 - 1))); // [3]
filter.coefficients = juce::dsp::IIR::Coefficients::makeFirstOrderLowPass (sampleRateHz, 4 * freqHz); // [4]
decayCoef = juce::jmap (decayTime, std::pow (Type (0.999), Type (length)), std::pow (Type (0.99999), Type (length))); // [5]
reset();
}
jmapconstexpr Type jmap(Type value0To1, Type targetRangeMin, Type targetRangeMax)Remaps a normalised value (between 0 and 1) to a target range.Definition juce_MathsFunctions.h:381
- [1]まず、ディレイ・ラインのサイズを、演奏された音の基本周波数のサンプル・レートに合わせます。これは、基本周波数がループに必要なサンプル数以上のサンプリング周波数に等しいという事実から取ったものだ。
- [2]次に、前方のディレイライン上のピックアップ位置のインデックスを取り出す。0.0 .. 1.0を遅延線の長さの0から半分の範囲に設定する。これは、極性が反転する進行波の2つの方向を含む全サイクルに対応するためです。後方の遅延ライン上のピックアップ位置のインデックスは、単純に前方のインデックスの逆数を取ることによって計算される。
- [3]の範囲から、前方遅延ライン上のトリガー位置のインデックスを同様にマッピングする。0.0 .. 1.0をディレイ・ラインの長さの0から半分の範囲に設定する。
- [4]減衰の挙動をシミュレートするローパスフィルターの係数は、基本波の4倍の周波数に設定される。
- [5]の範囲からマッピングし、減衰時間から減衰係数を算出する。0.0 .. 1.0の範囲にある。0.999^長さへの0.99999^長さ.これは、物理的に振動する弦で実際に起こる減衰がごくわずかであることを表している。
の中でreset()
関数でディレイラインをリセットし、残っている古いサンプルをクリアする:
void reset() noexcept
{
forwardDelayLine .clear();
backwardDelayLine.clear();
}
の中でprepare()
関数で、後で処理に使用する一時的なオーディオブロックを作成します。[6]を呼び出して、サンプルブロック間でサンプルレートが変更された場合でも、パラメータが正しいことを確認します。updateParameters()
再び関数[7]:
void prepare (const juce::dsp::ProcessSpec& spec)
{
sampleRateHz = (Type) spec.sampleRate;
tempBlock = juce::dsp::AudioBlock (heapBlock, spec.numChannels, spec.maximumBlockSize); // [6]
filter.prepare (spec);
updateParameters(); // [7]
}
撥弦によって引き起こされる弦の励振を引き起こすには、2本の遅延線によって表される両方の波の初期変位を設定しなければならない。
でこれを行うにはtrigger()
関数では、まずディレイラインの先頭からトリガー位置のインデックスに含まれるサンプル間を繰り返し処理し、各サンプルのインデックスを音符のベロシティの半分に達する昇順の値にマッピングして値を計算し、これらを反対方向のディレイラインに割り当てます。[8].トリガー位置のインデックスからディレイラインの終端までの間に含まれるサンプルについて、音符のベロシティの半分から下降する値で同じことを行います。[9].
void trigger (Type velocity) noexcept
{
jassert (velocity >= Type (0) && velocity <= Type (1));
for (size_t i = 0; i <= forwardTriggerIndex; ++i) // [8]
{
auto value = juce::jmap (Type (i), Type (0), Type (forwardTriggerIndex), Type (0), velocity / 2);
forwardDelayLine .set (i, value);
backwardDelayLine.set (getDelayLineLength() - 1 - i, value);
}
for (size_t i = forwardTriggerIndex; i < getDelayLineLength(); ++i) // [9]
{
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);
}
}
バッファブロック内のすべてのサンプルを生成するために、サンプル生成の1反復のみを返すヘルパー関数を以下のように宣言する:
Type processSample() noexcept
{
auto forwardOut = forwardDelayLine .back(); // [10]
auto backwardOut = backwardDelayLine.back(); // [11]
forwardDelayLine .push (-backwardOut); // [12]
backwardDelayLine.push (-decayCoef * filter.processSample (forwardOut)); // [13]
return forwardDelayLine.get (forwardPickupIndex) + backwardDelayLine.get (backwardPickupIndex); // [14]
}
- [10]まず、順方向遅延ラインのサーキュラーバッファから、最も新しいサンプルを取り出す。
back()
関数は先に宣言した。 - [11]後方遅延ラインの最も新しいサンプルについても同じことをする。
- [12]次に、後方遅延ラインの境界に位置する最後のサンプルを、極性を反転させて前方遅延ラインに押し込む必要がある。
- [13]次に、もう片方のディレイラインも同じようにしますが、今度はサンプルをローパスフィルターでフィルターし、減衰係数を掛けて減衰させてから極性を反転させ、サンプルをサーキュラーバッファーに押し込みます。
- [14]最後に、両方のディレイ・ラインからの信号をそれぞれのピックアップ・インデックスで合計することによって、ピックアップ位置から録音されたサンプルを戻します。
の中でprocess()
関数を呼び出して、バッファブロック内のすべてのサンプルを処理するだけです。processSample()
ヘルパー関数を使用し、先に作成した一時ブロックに値を代入する。[15].次に、オーディオブロックのすべてのチャンネルにサンプルをコピーします。[16]そして、入力ブロックに含まれる元のコンテンツとともに、一時的なブロックのコンテンツを出力ブロックに追加する。[17].
template
void process (const ProcessContext& context) noexcept
{
auto&& outBlock = context.getOutputBlock();
auto numSamples = outBlock.getNumSamples();
auto* dst = tempBlock.getChannelPointer (0);
for (size_t i = 0; i < numSamples; ++i) // [15]
dst[i] = processSample();
for (size_t ch = 1; ch < tempBlock.getNumChannels(); ++ch) // [16]
juce::FloatVectorOperations::copy (tempBlock.getChannelPointer (ch),
tempBlock.getChannelPointer (0),
(int) numSamples);
outBlock.copyFrom (context.getInputBlock()).add (tempBlock.getSubBlock (0, outBlock.getNumSamples()));
}
の中でVoice
クラスにWaveguideString
プロセッサからプロセッサ・チェーンへ[18]に対応するインデックスを追加する。[19].
enum
{
oscIndex,
stringIndex, // [19]
masterGainIndex
};
juce::dsp::ProcessorChain, WaveguideString, juce::dsp::Gain> processorChain; // [18]
};
の中でnoteStarted()
関数で、オシレーターのレベルを設定する行を削除します。プロセッサーチェーンからストリングモデルへのリファレンスを取得します。[20]弦の基本周波数を演奏された音の周波数に設定する。[21]を呼び出すことで摘出をトリガーする。trigger()
関数を音符のベロシティ[22].
void noteStarted() override
{
auto velocity = getCurrentlyPlayingNote().noteOnVelocity.asUnsignedFloat();
auto freqHz = (float) getCurrentlyPlayingNote().getFrequencyInHertz();
processorChain.get().setFrequency (freqHz, true);
//processorChain.get().setLevel (velocity);
auto& stringModel = processorChain.get(); // [20]
stringModel.setFrequency (freqHz); // [21]
stringModel.trigger (velocity); // [22]
}
プログラムを実行し、どのように聞こえるか見てみよう。
ピックアップ/トリガーの位置やディケイ・タイム、フィルターの種類など、さまざまな導波管のパラメーターを試し、生成されるストリング・サウンドにどのような影響を与 えるかに注目してください。
この修正版のソースコードはDSPDelayLineTutorial_02.h
ファイルにある。
概要
このチュートリアルでは、ストリング・モデルと遅延線の実装方法を学びました。特に
- フィジカルモデリングとディレイラインの基礎を学ぶ。
- シンプルなタイムベースエフェクトのベースとなるディレイラインを実装。
- ディレイ・ラインを組み込んで、ステレオで面白いディレイ効果を生み出す。
- 物理モデリング技術に基づく導波管ストリングモデルを統合。
このチュートリアルのパート1に戻り、オシレーターとフィルターについて学びましょう:Tutorial: Introduction to DSP
ディストーションとコンボリューションを理解するには、このチュートリアルのパート2に戻ってください:Tutorial: Add distortion through waveshaping and convolution