チュートリアル: サイン波シンセサイザーの構築
このチュートリアルでは、シンプルなサイン波合成について紹介します。サイン波オシレーターの状態を管理し、音声出力にデータを書き込む方法を示します。
レベル: 中級
プラットフォーム: Windows, macOS, Linux
クラス: AudioAppComponent, Slider, MathConstants
はじめに
このチュートリアルは、Tutorial: Control audio levelsから続きます。先にそのチュートリアルを読んで理解しておく必要があります。
このチュートリアルのデモプロジェクトをダウンロードしてください: PIP | ZIP。プロジェクトを解凍し、Projucerで最初のヘッダーファイルを開いてください。
この手順でヘルプが必要な場合は、Tutorial: Projucer Part 1: Getting started with the Projucerを参照してください。
デモプロジェクト
このデモプロジェクトは、Projucerのオーディオアプリケーションテンプレートをベースにしています。ユーザーに単一のスライダーを表示して、サイン波の周波数を制御します。
サイン波の生成
このチュートリアルでは、標準ライブラリ関数std::sin()を使用してサイン波を合成します。これを使用するためには、現在の位相角と、各出力サンプルで位相角をインクリメントする必要がある量を格納して、サイン波生成の状態を維持する必要があります。このサンプルごとの変化量(「デルタ」)のサイズは、出力のサンプルレートと生成したいサイン波の周波数に依存します。
ほとんどの合成アプリケーションやプラグインは、おそらく最も効率的な技術ではないため、std::sin()関数を使用しない可能性があります。一般的にはウェーブテーブルが使用されます。Tutorial: Wavetable synthesisを参照してください。ウェーブテーブルは、サイン波以外の波形も可能にします。
状態の維持
MainContentComponentクラスでは、3つのdoubleメンバー[1]を格納します:
double currentSampleRate = 0.0, currentAngle = 0.0, angleDelta = 0.0; // [1]
angleDeltaメンバーを更新するシンプルな関数があります:
void updateAngleDelta()
{
auto cyclesPerSample = frequencySlider.getValue() / currentSampleRate; // [2]
angleDelta = cyclesPerSample * 2.0 * juce::MathConstants<double>::pi; // [3]
}
- [2]: まず、各出力サンプルで完了する必要があるサイクル数を計算します。
- [3]: 次に、これに完全なサイン波サイクルの長さである
2piラジアンを掛けます。
この関数が正しく動作する前に、出力サンプルレートを知る必要があります。これは、サンプルがどのくらいの頻度で生成されているかを知る必要があるためです。これは、サンプルごとに必要な変化量を知るためです。サンプルレートは、AudioAppComponent::prepareToPlay()コールバック関数によって渡されます:
void prepareToPlay (int, double sampleRate) override
{
currentSampleRate = sampleRate;
updateAngleDelta();
}
ここでは、サンプルレート値のコピーを保存し、最初にupdateAngleDelta()関数を呼び出します。
スライダー値の使用
アプリの実行中にスライダーが移動されたら、angleDeltaメンバーを再度更新する必要があります:
frequencySlider.onValueChange = [this] {
if (currentSampleRate > 0.0)
updateAngleDelta();
};
ここでは、サンプルレートが有効であることを確認してから、再度updateAngleDelta()関数を呼び出します。
音声の出力
getNextAudioBlock()コールバック中に、実際のサイン波を生成して出力に書き込む必要があります:
void getNextAudioBlock (const juce::AudioSourceChannelInfo& bufferToFill) override
{
auto level = 0.125f;
auto* leftBuffer = bufferToFill.buffer->getWritePointer (0, bufferToFill.startSample);
auto* rightBuffer = bufferToFill.buffer->getWritePointer (1, bufferToFill.startSample);
for (auto sample = 0; sample < bufferToFill.numSamples; ++sample)
{
auto currentSample = (float) std::sin (currentAngle);
currentAngle += angleDelta;
leftBuffer[sample] = currentSample * level;
rightBuffer[sample] = currentSample * level;
}
}
各出力サンプルについて、現在の角度のサイン関数を計算し、次のサンプルのために角度をインクリメントします。フルスケールのサイン波は非常に大きくなるため、レベルを0.125に下げていることに注意してください! 現在の角度値が2piに達したときに、ゼロにラップバックすることができます(そしておそらくそうすべきです)。より大きな値でも有効な値が返されるため、実際にはこの計算を避けることができます。次の画像に示すようなものが得られます:

スライダーの設定
スライダーの値が非線形に変化することに気付いたかもしれません(そうでない場合は、今試してみてください)。これらの変化は、実際には対数的です。これにより、小さい値に対してより高い解像度が得られ、大きい値に対してより低い解像度が得られます。周波数値を制御する場合、これはしばしば適切です(音楽的には、線形の等しい変化ではなく、周波数間の比率の等しい変化を聞くため)。これは、Slider::setSkewFactorFromMidPoint()関数[4]を使用して構成されます。スライダーの範囲は50..5000に設定されているため、スライダートラックの中心を500を表すように設定すると、スライダーの最小値と中心、および中心と最大値の間に等しい音楽的な間隔があることを意味します:
MainContentComponent()
{
addAndMakeVisible (frequencySlider);
frequencySlider.setRange (50.0, 5000.0);
frequencySlider.setSkewFactorFromMidPoint (500.0); // [4]
スライダーのスキューファクターは、Slider::setSkewFactor()関数を使用して直接設定できますが、中点でどの値が必要かを考える方が簡単なことがよくあります。
演習: アプリケーションに別のスライダーを追加して、サイン波のレベルを制御します。レベルは1.0よりも十分に低く保つように注意してください --- 最大値0.25で十分です。
周波数変化のスムージング
特に高周波数で、スライダーが移動されると、おそらく望ましくない可聴アーティファクトが生成されることに気付くかもしれません。これは、スライダーが実際には離散的なステップで変化しており、スライダーが速く移動されるとこれらのステップがかなり大きくなるためです。これに加えて、スライダーの周波数は各オーディオブロックに対してのみ更新されるため、これらの変更の正確な効果はハードウェアのブロックサイズに依存します。
スムージングのための状態メンバー
クラスに2つのメンバーを追加しましょう。1つは合成に使用されている現在の周波数を格納するため、もう1つはユーザーがスライダーを移動することで要求したターゲット周波数を格納するためです。次に、これらの値の間でよりゆっくりとランプすることで、アーティファクトを除去できます:
double currentFrequency = 500.0, targetFrequency = 500.0; // [5]
これらの値を同時に初期化します[5]。スライダーも同じ値に初期化できます[6]:
MainContentComponent()
{
addAndMakeVisible (frequencySlider);
frequencySlider.setRange (50.0, 5000.0);
frequencySlider.setSkewFactorFromMidPoint (500.0);
frequencySlider.setValue (currentFrequency, juce::dontSendNotification); // [6]
合成コードの更新
このアルゴリズムの動作の鍵は、現在の値とターゲット値が同じか異なるかをチェックすることです。同じ場合、angleDeltaメンバーを変更する必要がないため、元のコードを単純に使用できます。現在の値とターゲット値が異なる場合、現在の値をターゲットに徐々に近づけるときに、各サンプルのangleDeltaメンバーを更新する必要があります。
この例では、出力バッファ内のサンプル数をランプの持続時間として使用するだけです。これは、非常に小さいバッファサイズでは、まだアーティファクトが聞こえる可能性があることを意味します。
void getNextAudioBlock (const juce::AudioSourceChannelInfo& bufferToFill) override
{
auto level = 0.125f;
auto* leftBuffer = bufferToFill.buffer->getWritePointer (0, bufferToFill.startSample);
auto* rightBuffer = bufferToFill.buffer->getWritePointer (1, bufferToFill.startSample);
auto localTargetFrequency = targetFrequency;
if (!juce::approximatelyEqual (localTargetFrequency, currentFrequency)) // [7]
{
auto frequencyIncrement = (localTargetFrequency - currentFrequency) / bufferToFill.numSamples; // [8]
for (auto sample = 0; sample < bufferToFill.numSamples; ++sample)
{
auto currentSample = (float) std::sin (currentAngle);
currentFrequency += frequencyIncrement; // [9]
updateAngleDelta(); // [10]
currentAngle += angleDelta;
leftBuffer[sample] = currentSample * level;
rightBuffer[sample] = currentSample * level;
}
currentFrequency = localTargetFrequency;
}
else // [11]
{
for (auto sample = 0; sample < bufferToFill.numSamples; ++sample)
{
auto currentSample = (float) std::sin (currentAngle);
currentAngle += angleDelta;
leftBuffer[sample] = currentSample * level;
rightBuffer[sample] = currentSample * level;
}
}
}
- [7]: ターゲットが現在の値と異なるかどうかをチェックします。この関数の実行中にスライダーがメッセージスレッドで値を変更した場合に備えて、ターゲット値のローカルコピーを取得することに注意してください。
- [8]: サンプルごとに必要なインクリメントを計算します。
- [9]: 現在の周波数をインクリメントします。
- [10]: この新しい周波数に基づいて
deltaAngleメンバーを更新します。 - [11]: それ以外の場合は、元のコードを使用するだけです。
このコードの形式は、DSPコードの典型的なパターンを使用しています。可能であれば、内側のfor()ループ内の条件文を避けます。代わりに、ループの外側で条件をテストし、パラメータが変化しているかどうかに応じて、2つの異なるが非常に似たループを使用します。
最後に、ターゲット値を更新するだけにするために、Slider::onValueChangeヘルパーオブジェクトを更新する必要があります:
frequencySlider.onValueChange = [this] { targetFrequency = frequencySlider.getValue(); };
以上です! これを試してみると、スライダーの移動からのアーティファクトが除去されているはずです。
演習: 以前の演習で追加したレベルスライダーコントロールにスムージングを追加します。
Notes
- 位相角を
2piでラップしないことは、すべての状況で理想的ではない場合があります。doubleではなくfloat変数を使用している場合、現在の角度値が非常に大きくなると計算に不正確さが生じます。std::sin()関数を使用して2piで位相をラップしないことで、シンプルなウェーブテーブル技術と比較して合理的にうまく機能します。この探求については、Tutorial: Wavetable synthesisを参照してください。
まとめ
このチュートリアルでは、サイン波を合成および制御するための基本的な方法をいくつか紹介しました。以下を見てきました:
- サイン波オシレーターの状態を維持するために必要な基本的な変数。
- 望ましい結果を生成するためにこれらの変数を設定する方法。
- オーディオアーティファクトを回避するためにパラメータ変更をスムーズにする方法。